diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f87b0f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +prelude.json +dist/ +node_modules/ diff --git a/default-config.json b/default-config.json new file mode 100644 index 0000000..89508cb --- /dev/null +++ b/default-config.json @@ -0,0 +1,15 @@ +{ + // Web server port + port: 9847, + + // Absolute paths to scan for music + // Can be paths to directories, or individual files. + // To limit the nesting level of subdirectories to scan, use the format `path:limit`. + // The default nesting limit is 20. + // Example: + // `/home/user/Music:0` do not scan subdirectories + // `/home/user/Music:5` scan up to 5 subdirectories deep + discoverPaths: [ + + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..09c4d27 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,688 @@ +{ + "name": "@prelude-music/server", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@prelude-music/server", + "version": "0.0.0", + "license": "GPL-3.0-only", + "dependencies": { + "better-sqlite3": "^11.1.1", + "enhanced-switch": "^1.1.6", + "json5": "^2.2.3", + "mime": "^4.0.3", + "music-metadata": "^8.3.0" + }, + "devDependencies": { + "@types/node": "^20.14.9", + "typescript": "^5.5.2" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", + "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.1.1.tgz", + "integrity": "sha512-bAlQQb7gwCgxNpDYafK0O4AaIOiTwA7srfqRtBbw0Nsiq6P+qxEYGl3hLw+9C5jX2FVjKW7oxkSouxlJ+3VX8A==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-switch": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/enhanced-switch/-/enhanced-switch-1.1.6.tgz", + "integrity": "sha512-UTcvoULqXLbR2zw4Tt4/ou+cnEZQ3/eNX7aIFc/Za/T7alpBqoNBH3B1y/sbj3EhrrOX1ZLKUCFA77/b/bCFaw==", + "license": "LGPL-3.0", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-type": { + "version": "18.7.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.7.0.tgz", + "integrity": "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.2", + "strtok3": "^7.0.0", + "token-types": "^5.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mime": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.3.tgz", + "integrity": "sha512-KgUb15Oorc0NEKPbvfa0wRU+PItIEZmiv+pyAO2i0oTIVTJhlzMclU7w4RXWQrSOVH5ax/p/CkIO7KI4OyFJTQ==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/music-metadata": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-8.3.0.tgz", + "integrity": "sha512-Mjt+Mqea2gooB+14XhJBxuGJVXrmAlWgeyBHlYRKSl7RfA92ktoJBz+fZ25zOa0yqKqg47ocNAngWE/WQOPYbw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "content-type": "^1.0.5", + "debug": "^4.3.4", + "file-type": "^18.6.0", + "media-typer": "^1.1.0", + "strtok3": "^7.0.0", + "token-types": "^5.0.1" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.65.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.65.0.tgz", + "integrity": "sha512-ThjYBfoDNr08AWx6hGaRbfPwxKV9kVzAzOzlLKbk2CuqXE2xnCh+cbAGnwM3t8Lq4v9rUB7VfondlkBckcJrVA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/peek-readable": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz", + "integrity": "sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strtok3": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.0.0.tgz", + "integrity": "sha512-pQ+V+nYQdC5H3Q7qBZAz/MO6lwGhoC2gOAjuouGf/VO0m7vQRh8QNMl2Uf6SwAtzZ9bOw3UIeBukEGNJl5dtXQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/token-types": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", + "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", + "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed51d75 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "@prelude-music/server", + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "node ./dist/index.js", + "build": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "GPL-3.0-only", + "description": "", + "devDependencies": { + "@types/node": "^20.14.9", + "typescript": "^5.5.2" + }, + "dependencies": { + "better-sqlite3": "^11.1.1", + "enhanced-switch": "^1.1.6", + "json5": "^2.2.3", + "mime": "^4.0.3", + "music-metadata": "^8.3.0" + } +} diff --git a/src/Config.ts b/src/Config.ts new file mode 100644 index 0000000..3d7bae6 --- /dev/null +++ b/src/Config.ts @@ -0,0 +1,35 @@ +import File from "./File.js"; +import JsonResponse from "./response/JsonResponse.js"; + +export default class Config { + /** + * Port on which the web server will listen + */ + public readonly port: number = 9847; + + /** + * Absolute paths to music files and/or directories. + * + * When providing a directory path, you can add a colon and a number to the path to set a subdirectory search limit. + * By default, the limit is 20. + * + * Examples: + * - `/music` will check subdirectories of `/music` up to 20 levels deep + * - `/music:0` will only check for music files in `/music` and will not check subdirectories + * - `/music:1` will only check for music files in `/music` and direct subdirectories (1 level deep) + * - `/music/audio.flac` will include the file `audio.flac` + */ + public discoverPaths: string[] = []; + + public constructor(options: { + port?: typeof Config.prototype.port, + discoverPaths?: typeof Config.prototype.discoverPaths + }) { + if (options.port !== undefined) this.port = options.port; + if (options.discoverPaths !== undefined) this.discoverPaths = options.discoverPaths; + } + + public static async fromFile(file: File): Promise { + return new Config(await file.json()); + } +} diff --git a/src/File.ts b/src/File.ts new file mode 100644 index 0000000..1410687 --- /dev/null +++ b/src/File.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import path from "node:path"; +import mime from "mime"; +import JSON5 from "json5"; +import JsonResponse from "./response/JsonResponse.js"; + +export default class File { + /** + * Create new File + * @param path Absolute file path + * @param [type] File MIME type + * @param [directory] Whether this is a directory + */ + public constructor( + /** + * Absolute file path + */ + public readonly path: string, + /** + * File MIME type + */ + public readonly type: string | null = mime.getType(path), + /** + * Whether this is a directory + */ + public readonly directory: boolean | null = null + ) { + } + + /** + * Get file name + */ + public name(): string { + return path.basename(this.path); + } + + /** + * Get file extension + */ + public extension(): string { + return path.extname(this.path).replace(/^\./, ""); + } + + /** + * Stat + */ + public async stat(): Promise { + return await fs.promises.stat(this.path); + } + + /** + * Check if can be read + */ + public async isReadable(): Promise { + try { + await fs.promises.access(this.path, fs.constants.R_OK); + return true; + } + catch { + return false; + } + } + + /** + * Read file to Buffer + * @param [start] Start offset + * @param [end] End offset + */ + public async buffer(start?: number, end?: number): Promise { + return (await fs.promises.readFile(this.path)).subarray(start, end); + } + + /** + * Read file and parse using JSON5 + */ + public async json(): Promise { + return JSON5.parse((await this.buffer()).toString("utf8")); + } + + /** + * Copy file + * @param file Destination + */ + public async copy(file: File): Promise { + await fs.promises.copyFile(this.path, file.path); + } + + + /** + * Create file read stream + * @param [start] Start offset + * @param [end] End offset + */ + public stream(start?: number, end?: number): NodeJS.ReadableStream { + return fs.createReadStream(this.path, {start, end}); + } + + /** + * Get entries of directory + */ + public async files(): Promise { + return (await fs.promises.readdir(this.path, {withFileTypes: true})).map(this.fromDirent.bind(this)); + } + + /** + * Crate File from Dirent + */ + public fromDirent(dirent: fs.Dirent): File { + return new File(path.join(this.path, dirent.name), dirent.isDirectory() ? null : undefined, dirent.isDirectory() ? true : (dirent.isFile() ? false : null)); + } +} diff --git a/src/Library.ts b/src/Library.ts new file mode 100644 index 0000000..ceb96bb --- /dev/null +++ b/src/Library.ts @@ -0,0 +1,343 @@ +import crypto from "node:crypto"; +import File from "./File.js"; +import Music from "./resource/Music.js"; +import JsonResponse from "./response/JsonResponse.js"; +import Api from "./api/Api.js"; +import PageResponse from "./response/PageResponse.js"; +import SpotifyApi from "./SpotifyApi.js"; +import EnhancedSwitch from "enhanced-switch"; +import Config from "./Config.js"; + +class Library { + /** + * Supported audio file extensions + */ + public static readonly AUDIO_EXTENSIONS = [ + "aiff", "aifc", "aif", // AIFF / AIFF-C + "aac", // AAC + "ape", // APE + "asf", // ASF + "bwf", // BWF + "dff", // DSDIFF + "dsf", // DSF + "flac", // FLAC + "mp2", // MP2 + "mka", "mkv", // Matroska + "mp3", // MP3 + "mpc", // MPC + "m4a", "mp4", // MPEG 4 + "ogg", "oga", // Ogg + "opus", // Opus + "spx", // Speex + "ogv", // Theora + "vorbis", // Vorbis + "wav", // WAV + "webm", // WebM + "wv", // WV + "wma" // WMA + ]; + + private static readonly spotify = new SpotifyApi("https://api.spotify.com"); + + /** + * Get absolute paths of supported audio files from file system + * @param path Absolute path to parent directory or audio file + * @param subDirectoryLevel The maximum subdirectory nesting level to scan (0 to not scan subdirectories) + * @param [extensions] The file extensions to include + */ + public static async getFiles(path: string, subDirectoryLevel: number = 20, extensions: string[] = Library.AUDIO_EXTENSIONS): Promise { + if (subDirectoryLevel < 0) return []; + + const current = new File(path); + const stats = await current.stat(); + + if (!stats.isDirectory()) { + if (extensions.includes(current.extension())) + return [current]; + return []; + } + + const result: File[] = []; + const entries = await current.files(); + + for (const entry of entries) { + if (entry.directory === true) { + if (subDirectoryLevel > 0) + result.push(...await Library.getFiles(entry.path, subDirectoryLevel - 1, extensions)); + } + else if (extensions.includes(entry.extension())) + result.push(entry); + } + + return result; + } + + public static async from(config: Config): Promise { + const library = new Library(); + for (const path of config.discoverPaths) { + const music = await Promise.all((await Library.getFiles(path)).map(Music.fromFile.bind(Music))); + await library.addTrack(...music); + } + + return library; + } + + readonly #music: Map = new Map(); + + public constructor() { + } + + public getTracks(): Library.Track[] { + return Array.from(this.#music.values()); + } + + public getTrack(id: number): Library.Track | null { + return this.#music.get(id) ?? null; + } + + public nextTrackId(): number { + return this.#music.size === 0 ? 0 : Math.max(...this.#music.keys()) + 1; + } + + public async addTrack(...tracks: Music[]): Promise { + for (const music of tracks) { + let album: Library.Album | null = null; + if (music.albumName !== null) { + album = Array.from(this.#albums.values()).find(a => a.title === music.albumName && a.artist === music.artist) ?? null; + if (album === null) { + album = new Library.Album(music.albumName ?? "Unknown", music.artist ?? null, []); + this.#albums.set(album.id, album); + } + } + const track = music instanceof Library.Track ? music : new Library.Track(this.nextTrackId(), music, album); + for (const artistName of music.artists) { + const artist = this.#artists.get(Library.Artist.id(artistName)) ?? await (async () => { + try { + const image = await Library.spotify.getArtistImage(artistName, track.title); + return new Library.Artist(artistName, [], image); + } + catch (e) { + return new Library.Artist(artistName, [], null); + } + })(); + artist.addTrack(track); + this.#artists.set(artist.id, artist); + } + const artist = this.#artists.get(Library.Artist.id(music.artist ?? "Unknown Artist")) ?? await (async () => { + if (music.artist === null) return new Library.Artist("Unknown Artist", [], null); + try { + const image = await Library.spotify.getArtistImage(music.artist, track.title); + return new Library.Artist(music.artist, [], image); + } + catch (e) { + return new Library.Artist(music.artist, [], null); + } + })(); + artist.addTrack(track); + this.#artists.set(artist.id, artist); + this.#music.set(track.id, track); + if (album !== null) + album.addTrack(track); + } + } + + public clearTracks() { + this.#music.clear(); + } + + public static id(...args: (string | null | undefined)[]) { + return this.hash(args.map(s => { + const k = ([null, undefined].some(t => t === s) ? "" : s as string); + return k.length + k; + }).join("")); + } + + private static hash(string: string): string { + return crypto.createHash("sha1").update(string).digest("base64").replace(/=+$/, "").replace(/\/+/g, "-"); + } + + #albums: Map = new Map(); + + public getAlbums(): Library.Album[] { + return Array.from(this.#albums.values()); + } + + public getAlbum(id: string): Library.Album | null { + return this.#albums.get(id) ?? null; + } + + #artists: Map = new Map(); + + public getArtists(): Library.Artist[] { + return Array.from(this.#artists.values()); + } + + public getArtist(id: string): Library.Artist | null { + return this.#artists.get(id) ?? null; + } + + // HTTP + + public tracks(req: Api.Request): JsonResponse { + return PageResponse.from(req, this.getTracks(), music => music.get()); + } + + public albums(req: Api.Request): JsonResponse { + return PageResponse.from(req, new EnhancedSwitch(req.url.searchParams.get("sort")) + .case("title:desc", () => this.getAlbums().sort((a, b) => b.title.localeCompare(a.title))) + .case("title:asc", () => this.getAlbums().sort((a, b) => a.title.localeCompare(b.title))) + .case("tracks:desc", () => this.getAlbums().sort((a, b) => b.tracks().length - a.tracks().length)) + .case("tracks:asc", () => this.getAlbums().sort((a, b) => a.tracks().length - b.tracks().length)) + .case("duration:desc", () => this.getAlbums().sort((a, b) => b.duration() - a.duration())) + .case("duration:asc", () => this.getAlbums().sort((a, b) => a.duration() - b.duration())) + .default(this.getAlbums()) + .value, album => album.get()); + } + + public artists(req: Api.Request): JsonResponse { + return PageResponse.from(req, new EnhancedSwitch(req.url.searchParams.get("sort")) + .case("name:desc", () => this.getArtists().sort((a, b) => b.name.localeCompare(a.name))) + .case("name:asc", () => this.getArtists().sort((a, b) => a.name.localeCompare(b.name))) + .case("tracks:desc", () => this.getArtists().sort((a, b) => b.tracks().length - a.tracks().length)) + .case("tracks:asc", () => this.getArtists().sort((a, b) => a.tracks().length - b.tracks().length)) + .case("duration:desc", () => this.getArtists().sort((a, b) => b.duration() - a.duration())) + .case("duration:asc", () => this.getArtists().sort((a, b) => a.duration() - b.duration())) + .default(this.getArtists()) + .value, artist => artist.get()); + } +} + +namespace Library { + export class Track extends Music { + public constructor(public readonly id: number, music: Music, public readonly album: Album | null) { + super(music.file, music.meta, music); + } + + public get(): JsonResponse.Object { + return { + id: this.id, + title: this.title, + artists: this.artists, + artist: this.artist, + album: this.album ? { + id: this.album.id, + title: this.album.title, + artist: this.album.artist + } : null, + year: this.year, + genres: this.genres, + track: this.track, + disk: this.disk, + meta: { + duration: this.meta.duration, + channels: this.meta.channels, + sampleRate: this.meta.sampleRate, + bitrate: this.meta.bitrate, + lossless: this.meta.lossless + } + } + } + } + + export class Album { + #tracks: Track[] = []; + public readonly id: string; + + public constructor( + public readonly title: string, + public readonly artist: string | null, + tracks: Track[] + ) { + this.#tracks = tracks; + this.id = Library.Album.id(title, artist); + } + + public tracks() { + return this.#tracks.sort((a, b) => { + if (a.track === null && b.track === null) return 0; + if (a.track === null) return 1; + if (b.track === null) return -1; + return a.track.no - b.track.no; + }); + } + + public addTrack(track: Track) { + if (this.#tracks.some(t => t.id === track.id)) return; + this.#tracks.push(track); + } + + public async cover() { + for (let i = 0; i < Math.min(5, this.#tracks.length); ++i) { + const cover = await this.#tracks[i]!.cover(); + if (cover !== null) return cover; + } + return null; + } + + public duration() { + return this.#tracks.reduce((a, b) => a + b.meta.duration, 0); + } + + public get(): JsonResponse.Object { + return { + id: this.id, + title: this.title, + artist: this.artist, + tracks: this.tracks().length, + duration: this.duration(), + } + } + + public static id(name: string, artist: string | null) { + return Library.id(artist, name); + } + } + + export class Artist { + #tracks: Track[] = []; + public readonly id: string; + + public constructor( + public readonly name: string, + tracks: Track[], + public readonly image: string | null + ) { + this.#tracks = tracks; + this.id = Artist.id(this.name); + } + + public tracks() { + return this.#tracks.sort((a, b) => a.title.localeCompare(b.title)); + } + + public albums() { + return ([...new Set(this.#tracks.map(track => track.album))].filter(album => album !== null) as Album[]).sort((a, b) => b.tracks().length - a.tracks().length); + } + + public addTrack(track: Track) { + if (this.#tracks.some(t => t.id === track.id)) return; + this.#tracks.push(track); + } + + public duration() { + return this.#tracks.reduce((a, b) => a + b.meta.duration, 0); + } + + public get(): JsonResponse.Object { + return { + id: this.id, + name: this.name, + tracks: this.tracks().length, + albums: this.albums().length, + image: this.image, + duration: this.duration(), + } + } + + public static id(name: string) { + return Library.id(name); + } + } +} + +export default Library; diff --git a/src/Server.ts b/src/Server.ts new file mode 100644 index 0000000..0adeb79 --- /dev/null +++ b/src/Server.ts @@ -0,0 +1,42 @@ +import http from "node:http"; +import EnhancedSwitch from "enhanced-switch"; +import Config from "./Config.js"; +import Library from "./Library.js"; +import Api from "./api/Api.js"; +import JsonResponse from "./response/JsonResponse.js"; + +export default class Server { + private readonly http: http.Server; + + public constructor( + public readonly config: Config, + public readonly library: Library, + public readonly packageJson: JsonResponse.Object + ) { + const api = new Api(library, packageJson); + this.http = http.createServer(api.requestListener.bind(api)); + } + + public async listen(port: number = this.config.port): Promise { + return await new Promise(resolve => { + this.http.listen(port, () => resolve(this)); + this.http.on("error", (err) => { + if ("code" in err && typeof err.code === "string") new EnhancedSwitch(err.code) + .case("EADDRINUSE", () => console.error(`Error: Port ${port} is already in use.`)) + .case("EACCES", () => console.error(`Error: Port ${port} requires elevated privileges.`)) + .case("ERR_SOCKET_BAD_PORT", () => console.error(`Error: Port ${port} is out of range.`)) + .default(() => console.error(`Server error occurred`, err)); + process.exit(1); + }); + }); + } + + public async close(): Promise { + return await new Promise((resolve, reject) => { + this.http.close((err) => { + if (err !== undefined) reject(err); + else resolve(this); + }); + }); + } +} diff --git a/src/SpotifyApi.ts b/src/SpotifyApi.ts new file mode 100644 index 0000000..58ff46a --- /dev/null +++ b/src/SpotifyApi.ts @@ -0,0 +1,92 @@ +/** + * Basic Spotify API client + */ +class SpotifyApi { + public constructor( + private readonly baseUrl: string + ) {} + + #accessToken: SpotifyApi.AccessToken | null = null; + private async fetch(path: string, query: Record): Promise<{res: Response, json: any}> { + try { + if (this.#accessToken === null || this.#accessToken.hasExpired()) + this.#accessToken = await SpotifyApi.AccessToken.get(); + const init: RequestInit = { + headers: { + "Authorization": "Bearer " + this.#accessToken!.token + } + }; + const url = new URL(path, this.baseUrl); + url.search = new URLSearchParams(query).toString(); + const res = await fetch(url.toString(), init); + return {res, json: await res.json()}; + } + catch (e) { + throw new Error("Spotify API call failed", {cause: e}); + } + } + + public async getArtistImage(artist: string, track?: string): Promise { + try { + if (track === undefined) { + const res = await this.fetch("/v1/search", { + q: artist, + type: "artist", + limit: "1", + include_external: "audio" + }); + if (!res.res.ok || res.json.artists.items.length === 0 || res.json.artists.items[0].images.length === 0 || res.json.artists.items[0].name !== artist) return null; + return res.json.artists.items[0].images.sort((a: any, b: any) => b.width - a.width).find((img: any) => img.width >= 256)?.url ?? res.json.artists.items[0].images[0].url + } + else { + const res = await this.fetch("/v1/search", { + q: artist + " " + track, + type: "track", + limit: "1", + include_external: "audio" + }); + if (!res.res.ok || res.json.tracks.items.length === 0 || res.json.tracks.items[0].artists.length === 0 || res.json.tracks.items[0].artists[0].name !== artist) + return await this.getArtistImage(artist); + const spotifyArtist = await this.getArtist(res.json.tracks.items[0].artists[0].id); + if (spotifyArtist === null) return null; + return spotifyArtist.images.sort((a: any, b: any) => b.width - a.width).find((img: any) => img.width >= 256)?.url ?? res.json.artists.items[0].images[0].url; + } + } + catch (e) { + throw e; + } + } + + public async getArtist(id: string): Promise<{images: {url: string, height: number, width: number}[]} | null> { + try { + const res = await this.fetch("/v1/artists/" + id, {}); + if (!res.res.ok) return null; + return res.json; + } + catch (e) { + throw e; + } + } +} + +namespace SpotifyApi { + export class AccessToken { + private constructor( + public readonly token: string, + public readonly expiration: Date + ) {} + + public hasExpired(): boolean { + return new Date() > this.expiration; + } + + public static async get(): Promise { + const res = await fetch("https://open.spotify.com/get_access_token?reason=transport&productType=web_player"); + const json = await res.json(); + if (!res.ok) throw new Error("Could not obtain Spotify API access token", {cause: json}); + return new AccessToken(json.accessToken, new Date(Date.now() + json.accessTokenExpirationTimestampMs)); + } + } +} + +export default SpotifyApi; diff --git a/src/SystemFile.ts b/src/SystemFile.ts new file mode 100644 index 0000000..719e2c3 --- /dev/null +++ b/src/SystemFile.ts @@ -0,0 +1,23 @@ +import {fileURLToPath} from "node:url"; +import Path from "node:path"; +import File from "./File.js"; + +/** + * A file that is part of the source project + */ +export default class SystemFile extends File { + /** + * @param path Absolute file path inside the project root + * @param [type] File MIME type + * @param [directory] Whether this is a directory + */ + public constructor( + path: string, + type?: string | null, + directory?: boolean | null + ) { + super(Path.join(SystemFile.projectRoot, path), type, directory); + } + + public static readonly projectRoot = fileURLToPath(new URL("../", import.meta.url)); +} diff --git a/src/api/Api.ts b/src/api/Api.ts new file mode 100644 index 0000000..3dcf193 --- /dev/null +++ b/src/api/Api.ts @@ -0,0 +1,236 @@ +import http from "node:http"; +import {parse as queryStringParse} from "node:querystring"; +import EnhancedSwitch from "enhanced-switch"; +import ApiResponse from "../response/ApiResponse.js"; +import ErrorResponse from "../response/ErrorResponse.js"; +import JsonResponse from "../response/JsonResponse.js"; +import Library from "../Library.js"; +import FileResponse from "../response/FileResponse.js"; +import BufferResponse from "../response/BufferResponse.js"; +import PageResponse from "../response/PageResponse.js"; + +class Api { + public constructor( + private readonly library: Library, + private readonly packageJson: JsonResponse.Object + ) { + } + + public async requestListener(q: http.IncomingMessage, s: http.ServerResponse): Promise { + const req = await Api.Request.create(q, s); + req.res.setHeader("Accept-Ranges", "bytes"); + req.res.setHeader("Access-Control-Allow-Origin", req.headers.origin ?? "*"); + req.res.setHeader("Access-Control-Allow-Credentials", "true"); + req.res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, PATCH, DELETE, PURGE, OPTIONS"); + req.res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + + if (req.method === "OPTIONS") { + req.res.statusCode = 204; + req.res.end(); + return; + } + + const endpointNotFound = new ErrorResponse(404, "The requested endpoint `" + req.url.pathname + "` does not exist."); + + const parts = req.url.pathname.split("/").filter(p => p.length > 0); + + if (parts.length === 0) + return new JsonResponse({ + version: this.packageJson.version + }).send(req); + + (await new EnhancedSwitch>(parts[0]) + .case("tracks", async () => { + if (parts.length === 1) { + return new EnhancedSwitch(req.method) + .case("GET", this.library.tracks(req)) + .default(new ErrorResponse(405, "The HTTP method `" + req.method + "` is not allowed for endpoint `" + req.url.pathname + "`.")) + .value; + } + else { + const track = this.library.getTrack(Number.parseInt(parts[1]!, 10)); + if (track === null) + return new ErrorResponse(404, "The requested track is not part of this library."); + + if (parts.length === 2) + return new JsonResponse(track.get()); + + else return new EnhancedSwitch>(parts[2]!) + .case("audio", new FileResponse(track.file)) + .case("image", async () => { + const image = await track.cover(); + if (image === null) + return new ErrorResponse(404, "No cover art available for this track."); + return new BufferResponse(image.data, image.type); + }) + .default(endpointNotFound) + .value; + } + }) + .case("albums", async () => { + if (parts.length === 1) { + return new EnhancedSwitch(req.method) + .case("GET", this.library.albums(req)) + .default(new ErrorResponse(405, "The HTTP method `" + req.method + "` is not allowed for endpoint `" + req.url.pathname + "`.")) + .value; + } + else { + const album = this.library.getAlbum(parts[1]!); + if (album === null) + return new ErrorResponse(404, "The requested album is not part of this library."); + + if (parts.length === 2) + return new JsonResponse(album.get()); + else return new EnhancedSwitch>(parts[2]!) + .case("tracks", PageResponse.from(req, album.tracks(), track => track.get())) + .case("image", async () => { + const image = await album.cover(); + if (image === null) + return new ErrorResponse(404, "No cover art available for this album."); + return new BufferResponse(image.data, image.type); + }) + .default(endpointNotFound) + .value; + } + }) + .case("artists", async () => { + if (parts.length === 1) { + return new EnhancedSwitch(req.method) + .case("GET", this.library.artists(req)) + .default(new ErrorResponse(405, "The HTTP method `" + req.method + "` is not allowed for endpoint `" + req.url.pathname + "`.")) + .value; + } + else { + const artist = this.library.getArtist(parts[1]!); + if (artist === null) + return new ErrorResponse(404, "The requested artist is not part of this library."); + if (parts.length === 2) + return new JsonResponse(artist.get()); + else return new EnhancedSwitch>(parts[2]!) + .case("tracks", PageResponse.from(req, artist.tracks(), track => track.get())) + .case("albums", PageResponse.from(req, artist.albums(), album => album.get())) + .default(endpointNotFound) + .value; + } + }) + .default(endpointNotFound) + .value) + .send(req); + } +} + +namespace Api { + export class RawContent { + public constructor( + public readonly buffer: Buffer + ) { + } + } + + export class Request { + /** + * @internal + */ + public _handled: boolean = false; + + public get handled(): boolean { + return this._handled; + } + + /** + * @internal + */ + public _body: JsonResponse.Object | JsonResponse.Array | Api.RawContent = {}; + + public readonly url: URL; + + public static async create(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const request = new Request(req, res); + const contentType = request.req.headers["content-type"]; + if (contentType === undefined) + return request; + + const contentLengthHeader = request.req.headers["content-length"]; + if (contentLengthHeader === undefined) + return request; + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isFinite(contentLength) && contentLength > 0) + return request; + const data = Buffer.alloc(Number.parseInt(contentLengthHeader, 10)); + let offset = 0; + for await (const chunk of request.req) { + if (offset + chunk.length > data.length) + return request; + data.set(chunk, offset); + offset += chunk.length; + } + + new EnhancedSwitch(contentType.toLowerCase().trim()) + .case("application/json", () => { + try { + request._body = JSON.parse(data.toString()); + } + catch (e) { + const err: SyntaxError = e as SyntaxError; + request.end(new ErrorResponse(400, "The request body is not valid JSON: " + err.message, err)); + } + }) + .case("application/x-www-form-urlencoded", () => { + request._body = queryStringParse(data.toString()); + }) + .default(() => { + request._body = new RawContent(data); + }); + + return request; + } + + private constructor( + public readonly req: http.IncomingMessage, + public readonly res: http.ServerResponse + ) { + this.url = new URL(`http://${process.env.HOST ?? "localhost"}${req.url ?? "/"}`); + } + + public end(res: ApiResponse) { + if (this._handled) return; + res.send(this); + this._handled = true; + } + + public get method(): string { + return this.req.method ?? ""; + } + + public get headers(): http.IncomingHttpHeaders { + return this.req.headers; + } + + /** + * Get request params for limit & page. Limit is inclusive and page starts at 1. + */ + public limit() { + const limitParam = Number.parseInt(this.url.searchParams.get("limit") ?? "100", 10); + const pageParam = Number.parseInt(this.url.searchParams.get("page") ?? "1", 10); + const limit = Number.isFinite(limitParam) && limitParam >= 0 ? limitParam : 100; + const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1; + return {limit, page}; + } + } + + export class Resource { + public delete(req: Request): ApiResponse { + return new ErrorResponse(405, "The HTTP method `" + req.method + "` is not allowed for endpoint `" + req.url.pathname + "`."); + } + + public put(req: Request): ApiResponse { + return new ErrorResponse(405, "The HTTP method `" + req.method + "` is not allowed for endpoint `" + req.url.pathname + "`."); + } + + public patch(req: Request): ApiResponse { + return new ErrorResponse(405, "The HTTP method `" + req.method + "` is not allowed for endpoint `" + req.url.pathname + "`."); + } + } +} + +export default Api; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ac92855 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,48 @@ +import Server from "./Server.js"; +import Config from "./Config.js"; +import Library from "./Library.js"; +import SystemFile from "./SystemFile.js"; +import JsonResponse from "./response/JsonResponse.js"; +import File from "./File.js"; + +const configArgIndex = process.argv.findIndex(arg => arg === "--config" || arg === "-c"); +const customConfig = configArgIndex >= 0 && process.argv.length > configArgIndex + 1; +const configFile: File = customConfig ? new File(process.argv[configArgIndex + 1]!) : new SystemFile("prelude.json"); + +try { + await configFile.isReadable(); + const stats = await configFile.stat(); + if (stats.isDirectory()) { + console.error(`Error: ${configFile.path} is a directory.`); + process.exit(1); + } +} +catch (e) { + if (!customConfig && e instanceof Error && "code" in e && e.code === "ENOENT") { + const defaultConfig = new SystemFile("default-config.json"); + await defaultConfig.copy(configFile); + } + throw e; +} + +console.log(`Loading config path=${configFile.path}`); +const config = await Config.fromFile(configFile); +const packageJson = await new SystemFile("/package.json", "application/json", false).json(); + +console.log("Loading library... (this may take a while)"); +const library = await Library.from(config); +console.log(`Library loaded. tracks=${library.getTracks().length} artists=${library.getArtists().length} albums=${library.getAlbums().length}`); + +const server = await new Server(config, library, packageJson).listen(); +console.log(`Server listening on http://0.0.0.0:${config.port}`); + +process.on("SIGINT", async () => { + console.log("\rStopping..."); + setTimeout(() => { + console.log("Did not stop in 5 seconds. Forcefully closing..."); + process.exit(0); + }, 5000); + await server.close(); + console.log("Server closed."); + process.exit(0); +}); diff --git a/src/resource/Music.ts b/src/resource/Music.ts new file mode 100644 index 0000000..6fdc1cb --- /dev/null +++ b/src/resource/Music.ts @@ -0,0 +1,166 @@ +import {parseFile as getMetadataFromFile} from "music-metadata"; +import File from "../File.js"; +import Api from "../api/Api.js"; + +class Music extends Api.Resource { + /** + * File + */ + public readonly file: File; + + /** + * Meta + */ + public readonly meta: Music.Meta; + + /** + * Track title + */ + public readonly title: string; + + /** + * Track artists + * @example ["Beth Hart", "Joe Bonamassa"] + */ + public readonly artists: string[]; + + /** + * Literal written track artist + * @example "Beth Hart & Joe Bonamassa" + */ + public readonly artist: string | null; + + /** + * Album title + */ + public readonly albumName: string | null; + + /** + * Year + */ + public readonly year: number | null; + + /** + * Genres + */ + public readonly genres: string[]; + + /** + * Track number on the media + * @example {no: 1, of: 2} + */ + public readonly track: {no: number, of: number | null} | null; + + /** + * Disk or media number + * @example {no: 1, of: 2} + */ + public readonly disk: {no: number, of: number | null} | null; + + /** + * Create new Music + * @param file See {@link File} + * @param meta See {@link Meta} + * @param [options] + * @param [options.title] See {@link Meta#title}. Defaults to name of file + * @param [options.artists] See {@link Meta#artists}. Defaults to empty array + * @param [options.artist] See {@link Meta#artist}. Defaults to `null` + * @param [options.albumName] See {@link Meta#albumName}. Defaults to `null` + * @param [options.year] See {@link Meta#year}. Defaults to `null` + * @param [options.genres] See {@link Meta#genres}. Defaults to empty array + * @param [options.track] See {@link Meta#track}. Defaults to `null` + * @param [options.disk] See {@link Meta#disk}. Defaults to `null` + */ + public constructor( + file: File, + meta: Music.Meta, + options: Partial<{ + title: typeof Music.prototype.title, + artists: typeof Music.prototype.artists, + artist: typeof Music.prototype.artist, + albumName: typeof Music.prototype.albumName, + year: typeof Music.prototype.year, + genres: typeof Music.prototype.genres, + track: typeof Music.prototype.track, + disk: typeof Music.prototype.disk + }> = {} + ) { + super(); + this.file = file; + this.meta = meta; + + this.title = options.title ?? file.name(); + this.artists = options.artists ?? []; + this.artist = options.artist ?? null; + this.albumName = options.albumName ?? null; + this.year = options.year ?? null; + this.genres = options.genres?.map(g => g.toLowerCase()) ?? []; + this.track = options.track ?? null; + this.disk = options.disk ?? null; + } + + /** + * Get cover art + */ + public async cover(): Promise<{type: string, data: Buffer} | null> { + const meta = await getMetadataFromFile(this.file.path); + if (meta.common.picture === undefined || meta.common.picture!.length === 0) return null; + const pic = meta.common.picture!.length === 1 ? meta.common.picture![0] : meta.common.picture!.find(p => ["cover", "front", "album"].some(t => p.type?.toLowerCase().includes(t))); + if (pic === undefined) return null; + return { + type: pic.format, + data: pic.data + }; + } + + public static async fromFile(file: File): Promise { + const meta = await getMetadataFromFile(file.path); + return new Music(file, { + duration: meta.format.duration ?? 0, + channels: meta.format.numberOfChannels ?? 0, + sampleRate: meta.format.sampleRate ?? 0, + bitrate: meta.format.bitrate ?? 0, + lossless: meta.format.lossless ?? false + }, { + title: meta.common.title, + artists: meta.common.artists ?? [], + artist: meta.common.artist ?? null, + albumName: meta.common.album ?? null, + year: meta.common.year ?? null, + genres: meta.common.genre ?? [], + track: meta.common.track !== undefined && typeof meta.common.track.no !== null ? {no: meta.common.track.no!, of: meta.common.track.of} : null, + disk: meta.common.disk !== undefined && typeof meta.common.disk.no !== null ? {no: meta.common.disk.no!, of: meta.common.disk.of} : null + }); + } +} + +namespace Music { + export interface Meta { + /** + * Audio duration in seconds + */ + readonly duration: number; + + /** + * The number of audio channels + */ + readonly channels: number; + + /** + * Sample rate in Hz + */ + readonly sampleRate: number; + + /** + * Bitrate in bits per second + */ + readonly bitrate: number; + + /** + * Whether the audio format is lossless. + */ + readonly lossless: boolean; + } +} + +export default Music; diff --git a/src/response/ApiResponse.ts b/src/response/ApiResponse.ts new file mode 100644 index 0000000..bee2914 --- /dev/null +++ b/src/response/ApiResponse.ts @@ -0,0 +1,7 @@ +import Api from "../api/Api.js"; + +export default abstract class ApiResponse { + protected constructor(public readonly status: number) { + } + public abstract send(req: Api.Request): void | Promise; +} diff --git a/src/response/BufferResponse.ts b/src/response/BufferResponse.ts new file mode 100644 index 0000000..29259e5 --- /dev/null +++ b/src/response/BufferResponse.ts @@ -0,0 +1,17 @@ +import ApiResponse from "./ApiResponse.js"; +import Api from "../api/Api.js"; + +export default class BufferResponse extends ApiResponse { + public constructor( + public readonly buffer: Buffer, + public readonly contentType: string = "application/octet-stream", + status: number = 200 + ) { + super(status); + } + + public override send(req: Api.Request) { + req.res.setHeader("Content-Type", this.contentType); + req.res.end(this.buffer); + } +} diff --git a/src/response/ErrorResponse.ts b/src/response/ErrorResponse.ts new file mode 100644 index 0000000..45099f9 --- /dev/null +++ b/src/response/ErrorResponse.ts @@ -0,0 +1,15 @@ +import JsonResponse from "./JsonResponse.js"; +import Api from "../api/Api.js"; + +export default class ErrorResponse extends JsonResponse { + public constructor(status: number, message: string, private readonly cause?: Error) { + super({error: {message}}, status); + } + + public override send(req: Api.Request): void { + req.res.statusCode = this.status; + super.send(req); + + if (this.cause !== undefined) console.error(`Request error: ${req.req.socket.remoteAddress} ${req.method} ${req.url}\n`, this.cause); + } +} diff --git a/src/response/FileResponse.ts b/src/response/FileResponse.ts new file mode 100644 index 0000000..5b7c7b0 --- /dev/null +++ b/src/response/FileResponse.ts @@ -0,0 +1,176 @@ +import ApiResponse from "./ApiResponse.js"; +import File from "../File.js"; +import Api from "../api/Api.js"; +import ErrorResponse from "./ErrorResponse.js"; + +class FileResponse extends ApiResponse { + public constructor(public readonly file: File, status: number = 200) { + super(status); + } + + public override async send(req: Api.Request) { + let stats; + try { + stats = await this.file.stat(); + } + catch (e) { + if (e instanceof Error && "code" in e && e.code === "ENOENT") + return new ErrorResponse(404, "File not found").send(req); + return new ErrorResponse(500, "Internal server error", e as Error).send(req); + } + + const rangeHeader = req.req.headers["range"]; + let ranges: FileResponse.Ranges | null; + try { + ranges = rangeHeader !== undefined ? FileResponse.Ranges.from(rangeHeader) : null; + } + catch (e) { + if (e instanceof SyntaxError) + return new ErrorResponse(400, e.message).send(req); + else return new ErrorResponse(500, "Internal server error", e as Error).send(req); + } + if (ranges !== null && this.status === 200) { + if (ranges.ranges.length === 0) return new ErrorResponse(400, "No ranges were specified").send(req); + let absoluteRanges: FileResponse.Ranges; + try { + absoluteRanges = ranges.absolute(stats.size); + } + catch (e) { + if (e instanceof RangeError) + return new ErrorResponse(416, e.message).send(req); + else return new ErrorResponse(500, "Internal server error", e as Error).send(req); + } + if (absoluteRanges.ranges.length === 1) { + req.res.setHeader("Content-Type", this.file.type ?? "application/octet-stream"); + req.res.setHeader("Content-Range", "bytes " + absoluteRanges.ranges[0]!.start + "-" + absoluteRanges.ranges[0]!.end + "/" + stats.size); + req.res.statusCode = 206; + this.file.stream(absoluteRanges.ranges[0]!.start, absoluteRanges.ranges[0]!.end).pipe(req.res); + return; + } + } + req.res.setHeader("Content-Type", this.file.type ?? "application/octet-stream"); + this.file.stream().pipe(req.res); + } +} + +namespace FileResponse { + export class Range { + public constructor( + public unit: "bytes", + public start: number | null, + public end: number + ) { + } + + /** + * @param size Byte size of the data for calculating `suffix-length` + * @throws {RangeError} If the range is unsatisfiable (return as 416 to client) + */ + public absolute(size: number): AbsoluteRange { + if (typeof this.start === "number") { + if (this.start < 0 || this.start >= size) throw new RangeError("Bad range start `" + this.start + "`"); + const end = Number.isFinite(this.end) ? this.end : size - 1; + if (end >= size || end < this.start) throw new RangeError("Bad range end `" + end + "`"); + return new AbsoluteRange(this.unit, this.start, end); + } + const start = size - this.end; + if (start < 0 || start >= size) throw new RangeError("Bad range start `" + start + "`"); + return new AbsoluteRange(this.unit, start, size - 1); + } + } + + export class AbsoluteRange extends Range { + public override start: number; + + public constructor(unit: typeof Range.prototype.unit, start: number, end: number) { + super(unit, start, end); + this.start = start; + } + + /** + * This is already an absolute range. + */ + public override absolute(): this { + return this; + } + } + + export class Ranges { + #ranges: T[] = []; + public constructor( + ranges: T[] + ) { + this.#ranges = ranges; + } + + public get ranges(): readonly T[] { + return Object.freeze(this.#ranges); + } + + /** + * @param size Byte size of the data for calculating `suffix-length` + * @throws {RangeError} If the range is unsatisfiable (return as 416 to client) + * @see FileResponse.Range#absolute + */ + public absolute(size: number): Ranges { + try { + return new Ranges(this.ranges.map(range => range.absolute(size))); + } + catch (e) { + throw e; + } + } + + public optimised(): Ranges { + if (this.ranges.length === 0) return this; + const ranges = [...this.ranges]; + ranges.sort((a, b) => a.start === null || b.start === null ? -1 : a.start - b.start); + + const optimised: T[] = []; + let current = ranges[0]!; + + for (const range of ranges) { + if (range.start === null) continue; + if (current.start === null) { + current = range; + continue; + } + if (range.start <= current.end) + current.end = Math.max(current.end, range.end); + else { + optimised.push(current); + current = range; + } + } + + optimised.push(current); + return new Ranges(optimised); + } + + /** + * @throws {SyntaxError} For issues with parsing the ranges. Should be sent as 400 + */ + public static from(rangeHeader: string) { + const result = new Ranges([]); + const [unit] = rangeHeader.split("="); + if (unit !== "bytes") throw new SyntaxError("The Range unit `" + unit + "` is not supported. Only `bytes` is supported."); + const rangesString = rangeHeader.slice(unit.length + 1); + if (rangesString.length === 0) throw new SyntaxError("No ranges specified in the Range header."); + const ranges = rangesString.split(","); + for (const range of ranges) { + const parts = range.split("-"); + if (parts.length !== 2) throw new SyntaxError("Invalid range `" + range + "` in the Range header."); + const startNumber = Number.parseInt(parts[0]!, 10); + const endNumber = Number.parseInt(parts[1]!, 10); + const start = Number.isNaN(startNumber) || !Number.isFinite(startNumber) ? null : startNumber; + const end = Number.isNaN(endNumber) || !Number.isFinite(endNumber) ? null : endNumber; + if (start === null && end === null) throw new SyntaxError("Invalid range `" + range + "` in the Range header."); + result.#ranges.push(new Range(unit, start, end ?? Infinity)); + } + + return result; + } + } +} + +export default FileResponse; diff --git a/src/response/JsonResponse.ts b/src/response/JsonResponse.ts new file mode 100644 index 0000000..89d2137 --- /dev/null +++ b/src/response/JsonResponse.ts @@ -0,0 +1,24 @@ +import ApiResponse from "./ApiResponse.js"; +import Api from "../api/Api.js"; + +class JsonResponse extends ApiResponse { + private readonly data: Readonly; + + public constructor(data: JsonResponse.Object | JsonResponse.Array, status: number = 200) { + super(status); + this.data = Object.freeze(data); + } + + public override send(req: Api.Request): void { + req.res.setHeader("Content-Type", "application/json"); + req.res.end(JSON.stringify(this.data)); + } +} + +namespace JsonResponse { + export type Value = string | number | boolean | Date | null | JsonResponse.Array | JsonResponse.Object; + export type Array = Value[] | readonly []; + export type Object = NodeJS.Dict | {[key: string]: Value}; +} + +export default JsonResponse; diff --git a/src/response/PageResponse.ts b/src/response/PageResponse.ts new file mode 100644 index 0000000..c966a63 --- /dev/null +++ b/src/response/PageResponse.ts @@ -0,0 +1,36 @@ +import JsonResponse from "./JsonResponse.js"; +import Api from "../api/Api.js"; + +export default class PageResponse extends JsonResponse { + public constructor(resources: JsonResponse.Object[], page: number, limit: number, total: number = resources.length) { + super({resources, page, limit, total}, 200); + } + + public static array(limits: { page: number, limit: number }, array: T[]): { + resources: T[], + page: number, + limit: number, + total: number + } { + return { + resources: array.slice((limits.page - 1) * limits.limit, limits.page * limits.limit), + page: limits.page, + limit: limits.limit, + total: array.length + }; + } + + public static from( + ...args: T extends JsonResponse.Object + ? [Api.Request, T[]] | [Api.Request, T[], (resource: T, index: number, array: T[]) => JsonResponse.Object] + : [Api.Request, T[], (resource: T, index: number, array: T[]) => JsonResponse.Object] + ): PageResponse { + const req = args[0]; + const resources = args[1]; + const mapFn = args[2]; + + const data = this.array(req.limit(), resources); + const res: JsonResponse.Object[] = (mapFn ? data.resources.map(mapFn) : data.resources) as JsonResponse.Object[]; + return new PageResponse(res, data.page, data.limit, data.total); + } +}