diff --git a/README.md b/README.md index 930d804..24678a6 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ currently you can only get basic widevine ones, everything related to playready guaranteed formats to work include: -- aac-legacy -- aac-he-legacy +- `aac_legacy` +- `aac_he_legacy` ## screenshots diff --git a/eslint.config.mjs b/eslint.config.mjs index c1a8ed5..8773963 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,33 +1,55 @@ import typescriptEslint from "typescript-eslint"; +import stylistic from "@stylistic/eslint-plugin"; export default [ + ...typescriptEslint.configs.strict, + ...typescriptEslint.configs.stylistic, + { + plugins: { + "@typescript-eslint": typescriptEslint.plugin, + "@stylistic": stylistic + } + }, { ignores: [ - "**/dist/*", - "**/result/*", - "**/node_modules/*" + "**/dist/**", + "**/result/**", + "**/node_modules/**" ] }, - ...typescriptEslint.configs.strict, { rules: { - "quotes": ["error", "double"], - "semi": ["error", "always"], - "comma-dangle": ["error", "never"], + "@stylistic/indent": ["error", 4], + "@stylistic/quotes": ["error", "double"], + "@stylistic/semi": ["error", "always"], + "@stylistic/comma-dangle": ["error", "never"], - // TODO: find a rule to make it so that relative imports are needed - // this is because those pass type checking, and fails at runtime - // not very typescript-coded (typescript shouldnt require runtime debugging, thats the point of it) + // TODO: make imports forced to be dynamic - // TODO: find a rule to make seperators on interfaces consistent - // it... let's just say... it pmo + "@stylistic/member-delimiter-style": [ + "error", + { + multilineDetection: "brackets", + multiline: { + delimiter: "semi", + requireLast: true + }, + singleline: { + delimiter: "comma", + requireLast: false + } + } + ], "@typescript-eslint/explicit-function-return-type": "error", "@typescript-eslint/no-unused-vars": [ "error", { varsIgnorePattern: "^_", - argsIgnorePattern: "^_" + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + caughtErrors: "all", + destructuredArrayIgnorePattern: "^_" } ] } diff --git a/flake.nix b/flake.nix index f530db9..1da1fe6 100644 --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ # uncomment this and let the build fail, then get the current hash # very scuffed but endorsed! # npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; - npmDepsHash = "sha256-1p/QMZQoFwXxXxlTYTtdHQ3O7p+RSzD3RpoKRB40CHg="; + npmDepsHash = "sha256-lvueqcSBjtt9RSMwq2NWCAVT0NrZwDmhEYkjtdOs7js="; nativeBuildInputs = with pkgs; [ makeWrapper ]; @@ -40,7 +40,8 @@ --prefix PATH : ${makeBinPath buildInputs} \ --add-flags "$out/dist/src/index.js" \ --set VIEWS_DIR $out/views \ - --set PUBLIC_DIR $out/public + --set PUBLIC_DIR $out/public \ + --set NODE_ENV production runHook postInstall ''; @@ -165,8 +166,8 @@ preStart = '' config='${cfg.stateDir}/config.toml' - cp -f '${toml.generate "config.toml" cfg.config}' "$config" - ''; # TODO: symlink instead of cp, shouldn't matter for reproducibility since its preStart but whatever + ln -sf '${toml.generate "config.toml" cfg.config}' "$config" + ''; serviceConfig = { Type = "simple"; diff --git a/package-lock.json b/package-lock.json index 079ba2d..fb61e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,20 +29,21 @@ "zod-validation-error": "^4.0.1" }, "devDependencies": { + "@stylistic/eslint-plugin": "^3.1.0", "@types/express": "^5.0.3", "@types/source-map-support": "^0.5.10", "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/parser": "^7.12.0", "concurrently": "^9.2.0", - "eslint": "^8.57.0", + "eslint": "^8.57.1", "typescript": "^5.9.2", - "typescript-eslint": "^7.13.0" + "typescript-eslint": "^8.39.1" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", "dependencies": { @@ -393,6 +394,70 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@stylistic/eslint-plugin": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", + "integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.13.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -520,40 +585,6 @@ "@types/serve-static": "*" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@typescript-eslint/parser": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", @@ -583,6 +614,42 @@ } } }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", + "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.39.1", + "@typescript-eslint/types": "^8.39.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", @@ -601,32 +668,133 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", + "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" - }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz", + "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", + "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.39.1", + "@typescript-eslint/tsconfig-utils": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", + "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/@typescript-eslint/types": { @@ -673,26 +841,132 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", + "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", + "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", + "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.39.1", + "@typescript-eslint/tsconfig-utils": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", + "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/@typescript-eslint/visitor-keys": { @@ -755,9 +1029,9 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -3518,30 +3792,197 @@ } }, "node_modules/typescript-eslint": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-7.18.0.tgz", - "integrity": "sha512-PonBkP603E3tt05lDkbOMyaxJjvKqQrXsnow72sVeOFINDE/qNmnnd+f9b4N+U7W6MXnnYyrhtmF2t08QWwUbA==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.1.tgz", + "integrity": "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "7.18.0", - "@typescript-eslint/parser": "7.18.0", - "@typescript-eslint/utils": "7.18.0" + "@typescript-eslint/eslint-plugin": "8.39.1", + "@typescript-eslint/parser": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/utils": "8.39.1" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", + "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/type-utils": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.39.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz", + "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", + "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", + "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.39.1", + "@typescript-eslint/tsconfig-utils": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", + "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/typescript-eslint/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/uglify-js": { diff --git a/package.json b/package.json index 08b683c..0ff254f 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,14 @@ "zod-validation-error": "^4.0.1" }, "devDependencies": { + "@stylistic/eslint-plugin": "^3.1.0", "@types/express": "^5.0.3", "@types/source-map-support": "^0.5.10", "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/parser": "^7.12.0", "concurrently": "^9.2.0", - "eslint": "^8.57.0", + "eslint": "^8.57.1", "typescript": "^5.9.2", - "typescript-eslint": "^7.13.0" + "typescript-eslint": "^8.39.1" } } diff --git a/src/appleMusicApi/index.ts b/src/appleMusicApi/index.ts index 4347ea5..3935cce 100644 --- a/src/appleMusicApi/index.ts +++ b/src/appleMusicApi/index.ts @@ -95,8 +95,8 @@ export default class AppleMusicApi { U extends RelationshipTypes = ["tracks"] > ( term: string, - limit: number = 25, - offset: number = 0, + limit = 25, + offset = 0, extend: T = [] as unknown[] as T, relationships: U = ["tracks"] as U ): Promise> { @@ -118,10 +118,10 @@ export default class AppleMusicApi { async getWebplayback( trackId: string ): Promise { - // this is one of those endpoints that returns a 200 - // no matter what happens, even if theres an error - // so we gotta do this stuuuupid hack - // TODO: find a better way to do this + // no way around this + // as we know, we can't have fun things with "WOA" urls + // https://files.catbox.moe/5oqolg.png (THE LINK WAS CENSORED?? TAKEN DOWN FROM CNN??) + // https://files.catbox.moe/wjxwzk.png const res = await this.http.post(webplaybackApiUrl, { salableAdamId: trackId, language: config.downloader.api.language @@ -162,8 +162,8 @@ export default class AppleMusicApi { names: string | string[], relationships: string[], extend: string[] - ): { [scope: string]: string } { - const params: { [scope: string]: string } = {}; + ): Record { + const params: Record = {}; for (const name of Array.isArray(names) ? names : [names]) { for (const relationship of relationships) { params[`include[${name}]`] = relationship; } @@ -179,5 +179,5 @@ export const appleMusicApi = new AppleMusicApi(env.ITUA, config.downloader.api.l // these are super special types // i'm not putting this in the ./types folder. // maybe ltr bleh -export type WebplaybackResponse = { songList: { assets: { flavor: string, URL: string }[], songId: string }[] }; -export type WidevineLicenseResponse = { license: string | undefined }; +export interface WebplaybackResponse { songList: { assets: { flavor: string, URL: string }[], songId: string }[] }; +export interface WidevineLicenseResponse { license: string | undefined }; diff --git a/src/appleMusicApi/token.ts b/src/appleMusicApi/token.ts index 58de2c0..522a086 100644 --- a/src/appleMusicApi/token.ts +++ b/src/appleMusicApi/token.ts @@ -27,7 +27,7 @@ export async function getToken(baseUrl: string): Promise { throw new Error("could not find match for the api token in the index javascript file"); } - log.debug("got token"); + log.debug("got api token"); return token; } diff --git a/src/appleMusicApi/types/attributes.ts b/src/appleMusicApi/types/attributes.ts index 248b0ce..39fc2d8 100644 --- a/src/appleMusicApi/types/attributes.ts +++ b/src/appleMusicApi/types/attributes.ts @@ -6,68 +6,68 @@ import type { } from "./extensions.js"; export type AlbumAttributes< - T extends AlbumAttributesExtensionTypes, + T extends AlbumAttributesExtensionTypes > = { - artistName: string - artwork: Artwork - contentRating?: string - copyright?: string - editorialNotes?: EditorialNotes - genreNames: string[] - isCompilation: boolean - isComplete: boolean - isMasteredForItunes: boolean - isSingle: boolean - name: string - playParams?: PlayParameters - recordLabel?: string - releaseDate?: string - trackCount: number - upc?: string - url: string + artistName: string; + artwork: Artwork; + contentRating?: string; + copyright?: string; + editorialNotes?: EditorialNotes; + genreNames: string[]; + isCompilation: boolean; + isComplete: boolean; + isMasteredForItunes: boolean; + isSingle: boolean; + name: string; + playParams?: PlayParameters; + recordLabel?: string; + releaseDate?: string; + trackCount: number; + upc?: string; + url: string; } - & Pick + & Pick; export type PlaylistAttributes< - T extends PlaylistAttributesExtensionTypes, + T extends PlaylistAttributesExtensionTypes > = { - artwork?: Artwork - curatorName: string - description?: DescriptionAttribute, - isChart: boolean, - lastModifiedDate?: string - name: string - playlistType: string - playParams?: PlayParameters - url: string + artwork?: Artwork; + curatorName: string; + description?: DescriptionAttribute; + isChart: boolean; + lastModifiedDate?: string; + name: string; + playlistType: string; + playParams?: PlayParameters; + url: string; } - & Pick + & Pick; export type SongAttributes< - T extends SongAttributesExtensionTypes, + T extends SongAttributesExtensionTypes > = { - albumName: string - artistName: string - artwork: Artwork - attribution?: string - composerName?: string - contentRating?: string - discNumber?: number - durationInMillis: number - editorialNotes?: EditorialNotes - genreNames: string[] - hasLyrics: boolean - isAppleDigitalMaster: boolean - isrc?: string - movementCount?: number - movementName?: string - movementNumber?: number - name: string - playParams?: PlayParameters - previews: Preview[] - releaseDate?: string - trackNumber?: number - url: string - workName?: string + albumName: string; + artistName: string; + artwork: Artwork; + attribution?: string; + composerName?: string; + contentRating?: string; + discNumber?: number; + durationInMillis: number; + editorialNotes?: EditorialNotes; + genreNames: string[]; + hasLyrics: boolean; + isAppleDigitalMaster: boolean; + isrc?: string; + movementCount?: number; + movementName?: string; + movementNumber?: number; + name: string; + playParams?: PlayParameters; + previews: Preview[]; + releaseDate?: string; + trackNumber?: number; + url: string; + workName?: string; } - & Pick + & Pick; diff --git a/src/appleMusicApi/types/extensions.ts b/src/appleMusicApi/types/extensions.ts index f1f553c..44be5ef 100644 --- a/src/appleMusicApi/types/extensions.ts +++ b/src/appleMusicApi/types/extensions.ts @@ -3,27 +3,27 @@ export type AnyAttributesExtensionTypes = AnyAttributesExtensionType[]; export type AlbumAttributesExtensionType = keyof AlbumAttributesExtensionMap; export type AlbumAttributesExtensionTypes = AlbumAttributesExtensionType[]; -export type AlbumAttributesExtensionMap = { - artistUrl: string - audioVariants?: string[] +export interface AlbumAttributesExtensionMap { + artistUrl: string; + audioVariants?: string[]; } export type PlaylistAttributesExtensionType = keyof PlaylistAttributesExtensionMap; export type PlaylistAttributesExtensionTypes = PlaylistAttributesExtensionType[]; -export type PlaylistAttributesExtensionMap = { - trackTypes: string[] +export interface PlaylistAttributesExtensionMap { + trackTypes: string[]; } export type SongAttributesExtensionType = keyof SongAttributesExtensionMap; export type SongAttributesExtensionTypes = SongAttributesExtensionType[]; -export type SongAttributesExtensionMap = { - artistUrl: string, - audioVariants?: string[], +export interface SongAttributesExtensionMap { + artistUrl: string; + audioVariants?: string[]; extendedAssetUrls: { - plus: string, - lightweight: string - superLightweight: string - lightweightPlus: string - enhancedHls: string - } + plus: string; + lightweight: string; + superLightweight: string; + lightweightPlus: string; + enhancedHls: string; + }; } diff --git a/src/appleMusicApi/types/extras.ts b/src/appleMusicApi/types/extras.ts index 89e5818..69d152f 100644 --- a/src/appleMusicApi/types/extras.ts +++ b/src/appleMusicApi/types/extras.ts @@ -1,38 +1,38 @@ // https://developer.apple.com/documentation/applemusicapi/descriptionattribute export interface DescriptionAttribute { - short?: string - standard: string + short?: string; + standard: string; } // https://developer.apple.com/documentation/applemusicapi/artwork export interface Artwork { - bgColor?: string - height: number - width: number - textColor1?: string - textColor2?: string - textColor3?: string - textColor4?: string - url: string + bgColor?: string; + height: number; + width: number; + textColor1?: string; + textColor2?: string; + textColor3?: string; + textColor4?: string; + url: string; } // https://developer.apple.com/documentation/applemusicapi/editorialnotes export interface EditorialNotes { - short?: string - standard?: string - name?: string - tagline?: string + short?: string; + standard?: string; + name?: string; + tagline?: string; } // https://developer.apple.com/documentation/applemusicapi/playparameters export interface PlayParameters { - id: string - kind: string + id: string; + kind: string; } // https://developer.apple.com/documentation/applemusicapi/preview export interface Preview { - artwork?: Artwork - url: string - hlsUrl?: string + artwork?: Artwork; + url: string; + hlsUrl?: string; } diff --git a/src/appleMusicApi/types/relationships.ts b/src/appleMusicApi/types/relationships.ts index 55229ca..ab05698 100644 --- a/src/appleMusicApi/types/relationships.ts +++ b/src/appleMusicApi/types/relationships.ts @@ -5,23 +5,23 @@ import type { AlbumAttributes, PlaylistAttributes, SongAttributes } from "./attr import type { AlbumAttributesExtensionTypes, AnyAttributesExtensionTypes, PlaylistAttributesExtensionTypes, SongAttributesExtensionTypes } from "./extensions.js"; // TODO: have something like this for every resource -export type Relationship = { +export interface Relationship { href?: string; next?: string; data: { // TODO: there is extra types here (id, type, etc) i just can't cba to add them lol // probably not important ! ahahahah // seems to be the same basic "resource" pattern i'm starting to notice (id(?), href, type, meta (not included), etc) - attributes: T - }[] + attributes: T; + }[]; } export type RelationshipType = keyof RelationshipTypeMap; export type RelationshipTypes = RelationshipType[]; -export type RelationshipTypeMap = { - albums: AlbumAttributes>, +export interface RelationshipTypeMap { + albums: AlbumAttributes>; // TODO: from what i can tell, playlists can NOT be used as a relationship type? kept in case - playlists: PlaylistAttributes>, + playlists: PlaylistAttributes>; // TODO: tracks can also be music videos, uh oh. - tracks: SongAttributes> + tracks: SongAttributes>; } diff --git a/src/appleMusicApi/types/responses.ts b/src/appleMusicApi/types/responses.ts index 1023d75..7e93688 100644 --- a/src/appleMusicApi/types/responses.ts +++ b/src/appleMusicApi/types/responses.ts @@ -14,18 +14,18 @@ export interface GetAlbumResponse< > { // https://developer.apple.com/documentation/applemusicapi/albums data: { - id: string, - type: "albums", - href: string, + id: string; + type: "albums"; + href: string; // https://developer.apple.com/documentation/applemusicapi/albums/attributes-data.dictionary - attributes: AlbumAttributes, + attributes: AlbumAttributes; // https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary relationships: { [K in U[number]]: Relationship< K extends RelationshipType ? RelationshipTypeMap[K] : never > - } - }[] + }; + }[]; } // https://developer.apple.com/documentation/applemusicapi/get-a-catalog-playlist @@ -35,18 +35,18 @@ export interface GetPlaylistResponse< > { // https://developer.apple.com/documentation/applemusicapi/playlists data: { - id: string - type: "playlists" - href: string + id: string; + type: "playlists"; + href: string; // https://developer.apple.com/documentation/applemusicapi/playlists/attributes-data.dictionary - attributes: SongAttributes + attributes: SongAttributes; // https://developer.apple.com/documentation/applemusicapi/playlists/relationships-data.dictionary relationships: { [K in U[number]]: Relationship< K extends RelationshipType ? RelationshipTypeMap[K] : never > - } - }[] + }; + }[]; } // https://developer.apple.com/documentation/applemusicapi/get-a-catalog-song @@ -56,18 +56,18 @@ export interface GetSongResponse< > { // https://developer.apple.com/documentation/applemusicapi/songs data: { - id: string - type: "songs" - href: string + id: string; + type: "songs"; + href: string; // https://developer.apple.com/documentation/applemusicapi/songs/attributes-data.dictionary - attributes: SongAttributes + attributes: SongAttributes; // https://developer.apple.com/documentation/applemusicapi/songs/relationships-data.dictionary relationships: { [K in U[number]]: Relationship< K extends RelationshipType ? RelationshipTypeMap[K] : never > - } - }[] + }; + }[]; } // TODO: support more than just albums @@ -85,19 +85,19 @@ export interface SearchResponse< albums?: { // https://developer.apple.com/documentation/applemusicapi/albums data: { - id: string, - type: "albums", - href: string, - attributes: AlbumAttributes>, + id: string; + type: "albums"; + href: string; + attributes: AlbumAttributes>; // https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary relationships: { [K in U[number]]: Relationship< K extends RelationshipType ? RelationshipTypeMap[K] : never > - } - }[], - href?: string, - next?: string, - } - } + }; + }[]; + href?: string; + next?: string; + }; + }; } diff --git a/src/cache.ts b/src/cache.ts index e40d4d4..3272db9 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -57,11 +57,12 @@ function removeCacheEntry(fileName: string): void { try { fs.unlinkSync(path.join(config.downloader.cache.directory, fileName)); } catch (err) { - log.error(`could not remove cache entry ${fileName}`); - log.error("this could result in 2 effects:"); - log.error("1. the cache entry will be removed, and the file never existed, operation is perfect, ignore this"); - log.error("2. the cache entry will be removed, but the file exists, so it will remain in the filesystem"); - log.error("if you experience the latter, the manual deletion of the file is required to fix this."); + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + log.debug(`file for cache entry ${fileName} missing, dropping`); + } else { + log.error(`could not remove cache entry ${fileName}!`); + log.error(err); + } } } diff --git a/src/constants/codecs.ts b/src/constants/codecs.ts index a44a2b9..b4b9249 100644 --- a/src/constants/codecs.ts +++ b/src/constants/codecs.ts @@ -1,6 +1,6 @@ import type { RegularCodecType } from "downloader/codecType.js"; -export const songCodecRegex: { [key in RegularCodecType]: RegExp } = { +export const songCodecRegex: Record = { "aac": /audio-stereo-\d+/, "aac_he": /audio-HE-stereo-\d+/, "aac_binaural": /audio-stereo-\d+-binaural/, diff --git a/src/downloader/fileMetadata.ts b/src/downloader/fileMetadata.ts index c72d84a..a713615 100644 --- a/src/downloader/fileMetadata.ts +++ b/src/downloader/fileMetadata.ts @@ -6,7 +6,6 @@ import { pipeline } from "node:stream/promises"; import { addToCache, isCached } from "../cache.js"; // TODO: simply add more fields. ha! -// TODO: add album cover // TODO: add lyrics (what format??) export class FileMetadata { public readonly artist: string; @@ -50,7 +49,7 @@ export class FileMetadata { this.composer = composer; } - public static fromSongResponse(trackMetadata: GetSongResponse<["extendedAssetUrls"], ["albums"]>): FileMetadata { + public static fromSongResponse(trackMetadata: GetSongResponse<[], ["albums"]>): FileMetadata { const trackAttributes = trackMetadata.data[0].attributes; const albumAttributes = trackMetadata.data[0].relationships.albums.data[0].attributes; @@ -102,8 +101,9 @@ export class FileMetadata { "-i", imagePath, "-map", "0", "-map", "1", + "-disposition:v", "attached_pic", "-c:a", "copy", - "-c:v", "mjpeg" + "-c:v", "copy" ]; } diff --git a/src/downloader/format.ts b/src/downloader/format.ts new file mode 100644 index 0000000..bd47042 --- /dev/null +++ b/src/downloader/format.ts @@ -0,0 +1,28 @@ +import type { SongAttributes } from "../appleMusicApi/types/attributes.js"; + +const illegalCharReplacements: Record = { + "?": "?", + "!": "!", + "*": "*", + "/": "/", + "\\": "\", + ":": ":", + "\"": """, + "<": "<", + ">": ">", + "|": "|" +}; + +// TODO: make these configurable, too opinionated right now +// eventually i'll make an account system? maybe you could do through there +// or i'll just make it config on the server +export function formatSong(trackAttributes: SongAttributes<[]>): string { + const title = trackAttributes.name.replace(/[?!*\/\\:"<>|]/g, (match) => illegalCharReplacements[match] || match); + const disc = trackAttributes.discNumber; + const track = trackAttributes.trackNumber; + + if (track === undefined) { throw new Error("track number is undefined in track attributes!"); } + if (disc === undefined) { throw new Error("disc number is undefined in track attributes!"); } + + return `${disc}-${track.toString().padStart(2, "0")} - ${title}`; +} diff --git a/src/downloader/index.ts b/src/downloader/index.ts index 3dc357c..44f573f 100644 --- a/src/downloader/index.ts +++ b/src/downloader/index.ts @@ -5,14 +5,19 @@ import { addToCache, isCached } from "../cache.js"; import type { RegularCodecType, WebplaybackCodecType } from "./codecType.js"; import type { GetSongResponse } from "../appleMusicApi/types/responses.js"; import { FileMetadata } from "./fileMetadata.js"; +import { createDecipheriv } from "node:crypto"; +import * as log from "../log.js"; + +export async function downloadSongFile(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType, songResponse: GetSongResponse<[], ["albums"]>): Promise { + log.debug("downloading song file and hopefully decrypting it"); + log.debug({ streamUrl: streamUrl, songCodec: songCodec }); -export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType, songResponse: GetSongResponse<["extendedAssetUrls"], ["albums"]>): Promise { let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1]; if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); } baseOutputName += `_${songCodec}`; const encryptedName = baseOutputName + "_enc.mp4"; const encryptedPath = path.join(config.downloader.cache.directory, encryptedName); - const decryptedName = baseOutputName + ".mp4"; + const decryptedName = baseOutputName + ".m4a"; const decryptedPath = path.join(config.downloader.cache.directory, decryptedName); if ( // TODO: remove check for encrypted file/cache for encrypted? @@ -58,3 +63,219 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son return decryptedPath; } + +// here's where shit gets real... +// here's also where i regret using javascript +// TODO: less mem alloc/access +// TODO: use actual atom scanning. what if the magic bytes appear in a sample +export async function fetchAndDecryptStreamSegment(segmentUrl: string, decryptionKey: string, fetchLength: number, offset: number): Promise { + log.debug("downloading and hopefully decrypting stream segment"); + log.debug({ segmentUrl: segmentUrl, offset: offset, fetchLength: fetchLength }); + + const response = await fetch(segmentUrl, { headers: { "range": `bytes=${offset}-${offset + fetchLength - 1}` }}); + + const file = new Uint8Array(await response.arrayBuffer()); + + // this translates to "moof" + const moof = new Uint8Array([0x6D, 0x6F, 0x6F, 0x66]); + const moofIndex = file.findIndex((_v, i) => { + return file.subarray(i, i + moof.length).every((byte, j) => { return byte === moof[j]; }); + }); + + const ivs = await extractIvFromFile(file); + const sampleLocs = await extractSampleLocationsFromFile(file); + + if (moofIndex !== -1) { + sampleLocs.forEach((loc, i) => { + const iv = ivs[i].value; + const subsamples = ivs[i].subsamples; + + const sample = file.subarray( // minus 4 because size + moofIndex + loc.offset - 4, + moofIndex + loc.offset + loc.size - 4 + ); + + if (subsamples.length > 0) { + let pos = 0; + + const decipher = createDecipheriv("aes-128-ctr", Buffer.from(decryptionKey, "hex"), Buffer.concat([iv, Buffer.alloc(8)])); + + subsamples.forEach(({ clearBytes, encryptedBytes }) => { + pos += clearBytes; + + if (encryptedBytes > 0) { + const chunk = sample.subarray(pos, pos + encryptedBytes); + const decryptedChunk = Buffer.concat([decipher.update(chunk), decipher.final()]); + decryptedChunk.copy(sample, pos); + pos += encryptedBytes; + } + }); + } else { + const decipher = createDecipheriv("aes-128-ctr", Buffer.from(decryptionKey, "hex"), Buffer.concat([iv, Buffer.alloc(8)])); + const decrypted = Buffer.concat([decipher.update(sample), decipher.final()]); + + file.set(decrypted, moofIndex + loc.offset - 4); + } + }); + } + + return file; +} + +interface IvValue { + value: Buffer; + subsamples: Subsample[]; +} + +interface Subsample { + clearBytes: number; + encryptedBytes: number; +} + +async function extractIvFromFile(file: Uint8Array): Promise { + const ivArray: IvValue[] = []; + + let maxSampleCount: number | undefined; + let subsampleEncryptionPresent = false; + + for (let i = 0; i < file.length; i++) { + // this translates to "senc" + if ( + file[i] === 0x73 && + file[i+1] === 0x65 && + file[i+2] === 0x6E && + file[i+3] === 0x63 + ) { + // skip 4 bytes -- skip "senc" header + i += 4; + + // skip 1 byte -- skip version + i += 1; + + const flags = (file[i] << 16) | (file[i+1] << 8) | file[i+2]; + + subsampleEncryptionPresent = (flags & 0x000002) !== 0; + + // skip 4 bytes -- skip flags + i += 3; + + // uint8x4 -> uint32x1 + maxSampleCount = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3]; + + // skip 4 bytes -- skip sample count + i += 4; + + for (let sampleIndex = 0; sampleIndex < maxSampleCount; sampleIndex++) { + const iv = file.subarray(i, i + 8); + + // skip 8 bytes -- skip iv + i += 8; + + const subsamples: Subsample[] = []; + if (subsampleEncryptionPresent) { + const subsampleCount = (file[i] << 8) | file[i+1]; + + // skip 2 bytes -- skip subsample count + i += 2; + + for (let j = 0; j < subsampleCount; j++) { + const clearBytes = (file[i] << 8) | file[i+1]; + + // skip 2 bytes -- skip clear bytes + i += 2; + + const encryptedBytes = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3]; + + // skip 4 bytes -- skip encrypted bytes + i += 4; + + subsamples.push({ + clearBytes: clearBytes, + encryptedBytes: encryptedBytes + }); + } + } + + ivArray.push({ + value: Buffer.from(iv), + subsamples: subsamples + }); + } + } + } + + return ivArray; +} + +interface SampleLocation { + offset: number; + size: number; +} + +async function extractSampleLocationsFromFile(file: Uint8Array): Promise { + const sampleLocations: SampleLocation[] = []; + + for (let i = 0; i < file.length; i++) { + // this translates to "trun" + if ( + file[i] === 0x74 && + file[i+1] === 0x72 && + file[i+2] === 0x75 && + file[i+3] === 0x6E + ) { + // skip 4 bytes -- skip "trun" header + i += 4; + + // skip 1 byte -- skip version + i += 1; + + const flags = (file[i] << 16) | (file[i+1] << 8) | file[i+2]; + + const dataOffsetPresent = (flags & 0x000001) !== 0; + const firstSampleFlagsPresent = (flags & 0x000004) !== 0; + const sampleDurationPresent = (flags & 0x000100) !== 0; + const sampleSizePresent = (flags & 0x000200) !== 0; + const sampleFlagsPresent = (flags & 0x000400) !== 0; + const sampleCompositionTimeOffsetsPresent = (flags & 0x000800) !== 0; + + // skip 3 bytes -- skip flags + i += 3; + + if (!dataOffsetPresent) { throw new Error("data offset not present in trun atom!"); } + if (!sampleSizePresent) { throw new Error("sample size not present in trun atom!"); } + + // TODO: add these flags + if (firstSampleFlagsPresent) { throw new Error("first sample flags not supported yet!"); } + if (sampleFlagsPresent) { throw new Error("sample flags not supported yet!"); } + if (sampleCompositionTimeOffsetsPresent) { throw new Error("sample composition time offsets not supported yet!"); } + + const sampleCount = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3]; + + // skip 4 bytes -- skip sample count + i += 4; + + let sampleDataOffset = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3]; + + // skip 4 bytes -- skip data offset + i += 4; + + for (let j = 0; j < sampleCount; j++) { + // honestly? i'm scared of what apple is doing to where this could be true + // for context, only ones that use subsample encryption have this... on the last segment????? + // truly something that you gotta ponder about for a second + // skip 4 bytes -- skip sample duration + if (sampleDurationPresent) { i += 4; } + + const sampleSize = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3]; + + // skip 4 bytes -- skip sample size + i += 4; + + sampleLocations.push({ offset: sampleDataOffset, size: sampleSize }); + sampleDataOffset += sampleSize; + } + } + } + + return sampleLocations; +} diff --git a/src/downloader/keygen.ts b/src/downloader/keygen.ts index 5756d2a..5094b2a 100644 --- a/src/downloader/keygen.ts +++ b/src/downloader/keygen.ts @@ -15,7 +15,7 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str let challenge: Buffer; try { challenge = session.createLicenseRequest(); - } catch (err) { + } catch (_err) { // for some reason, if gotten from a webplayback manifest, the pssh is in a completely different format // well, somewhat. it's just the raw data, we have to rebuild the pssh const rebuiltPssh = psshTools.widevine.encodePssh({ @@ -24,6 +24,9 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")] }); + // i'd love to log the error but it feels weird doing that and spammy + // also its the most useless error ever. "the pssh is not an actuall pssh" + // that typo is intentional, the library is like that log.warn("pssh was invalid, treating it as raw data (this is expected in the webplayback manifest)"); log.warn("this should not throw an error, unless the pssh data is actually invalid"); diff --git a/src/downloader/streamInfo.ts b/src/downloader/streamInfo.ts index 2f656e1..ec4362e 100644 --- a/src/downloader/streamInfo.ts +++ b/src/downloader/streamInfo.ts @@ -14,6 +14,8 @@ type M3u8 = ReturnType; export default class StreamInfo { public readonly trackId: string; public readonly streamUrl: string; + public readonly streamParsed: M3u8; + public readonly primaryFileUrl: string; public readonly widevinePssh: string | undefined; public readonly playreadyPssh: string | undefined; public readonly fairplayKey: string | undefined; @@ -21,20 +23,25 @@ export default class StreamInfo { private constructor( trackId: string, streamUrl: string, + streamParsed: M3u8, + primaryFileUrl: string, widevinePssh: string | undefined, playreadyPssh: string | undefined, fairplayKey: string | undefined ) { this.trackId = trackId; this.streamUrl = streamUrl; + this.streamParsed = streamParsed; + this.primaryFileUrl = primaryFileUrl; this.widevinePssh = widevinePssh; this.playreadyPssh = playreadyPssh; this.fairplayKey = fairplayKey; } - // TODO: why can't we decrypt widevine ones with this? + // why can't we decrypt widevine ones with this? // we get a valid key.. but it doesn't work :-( // upd: it seems thats just how the cookie crumbles. oh well + // upd: removed todo. as i said its just how it is public static async fromTrackMetadata(trackMetadata: SongAttributes<["extendedAssetUrls"]>, codec: RegularCodecType): Promise { log.warn("the track metadata method is experimental, and may not work or give correct values!"); log.warn("if there is a failure--use a codec that uses the webplayback method"); @@ -52,6 +59,10 @@ export default class StreamInfo { const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"]; const correctM3u8Url = m3u8Url.substring(0, m3u8Url.lastIndexOf("/")) + "/" + playlist.uri; + const correctM3u8 = await axios.get(correctM3u8Url, { responseType: "text" }); + const correctM3u8Parsed = hls.default.parse(correctM3u8.data); + + const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed); const widevinePssh = getWidevinePssh(drmInfos, drmIds); const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds); @@ -63,6 +74,8 @@ export default class StreamInfo { return new StreamInfo( trackId, correctM3u8Url, + correctM3u8Parsed, + primaryFileUrl, widevinePssh, playreadyPssh, fairplayKey @@ -87,6 +100,8 @@ export default class StreamInfo { const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); const m3u8Parsed = hls.default.parse(m3u8.data); + const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed); + const widevinePssh = m3u8Parsed.lines.find((line) => { return line.name === "key"; })?.attributes?.uri; if (widevinePssh === undefined) { throw new Error("widevine uri is missing!"); } if (typeof widevinePssh !== "string") { throw new Error("widevine uri is not a string!"); } @@ -95,6 +110,8 @@ export default class StreamInfo { return new StreamInfo( trackId, m3u8Url, + m3u8Parsed, + primaryFileUrl, widevinePssh, undefined, undefined @@ -102,7 +119,11 @@ export default class StreamInfo { } } -type DrmInfos = { [key: string]: { [key: string]: { "URI": string } }; }; +function getPrimaryFileUrl(m3u8Data: M3u8): string { + return m3u8Data.segments[0].uri; +} + +type DrmInfos = Record>;; function getDrmInfos(m3u8Data: M3u8): DrmInfos { // see `getAssetInfos` for the reason why this is so bad for (const line of m3u8Data.lines) { @@ -121,7 +142,7 @@ function getDrmInfos(m3u8Data: M3u8): DrmInfos { throw new Error("m3u8 missing audio session key info!"); } -type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; } +type AssetInfos = Record; function getAssetInfos(m3u8Data: M3u8): AssetInfos { // LOL??? THIS LIBRARY IS SO BAD // YOU CAN'T MAKE THIS SHIT UP diff --git a/src/web/endpoints/back/convertPlaylist.ts b/src/web/endpoints/back/convertPlaylist.ts new file mode 100644 index 0000000..562e236 --- /dev/null +++ b/src/web/endpoints/back/convertPlaylist.ts @@ -0,0 +1,89 @@ +import express from "express"; +import { validate } from "../../validate.js"; +import z from "zod"; +import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js"; +import { appleMusicApi } from "../../../appleMusicApi/index.js"; +import StreamInfo from "../../../downloader/streamInfo.js"; +import hls from "parse-hls"; +import { paths } from "../../openApi.js"; + +type M3u8 = ReturnType; + +const router = express.Router(); + +const path = "/convertPlaylist"; +const schema = z.object({ + query: z.object({ + id: z.string(), + codec: z.enum([...regularCodecTypeSchema.options, ...webplaybackCodecTypeSchema.options]) + }) +}); + +paths[path] = { + get: { + requestParams: { query: schema.shape.query }, + responses: { + 200: { description: "returns a m3u8 playlist for the song" }, + 400: { description: "bad request, invalid query parameters. sent as a zod error with details" }, + default: { description: "upstream api error, or some other error" } + } + } +}; + +router.get(path, async (req, res, next) => { + try { + const { id, codec } = (await validate(req, schema)).query; + + const codecType = new CodecType(codec); + + let m3u8Parsed: M3u8; + let streamUrl: string; + + if (codecType.regularOrWebplayback === "regular") { + const regularCodec = codecType.codecType as RegularCodecType; // safe cast, zod + const trackMetadata = await appleMusicApi.getSong(id); + const trackAttributes = trackMetadata.data[0].attributes; + const streamInfo = await StreamInfo.fromTrackMetadata(trackAttributes, regularCodec); + + m3u8Parsed = streamInfo.streamParsed; + streamUrl = streamInfo.streamUrl; + } else if (codecType.regularOrWebplayback === "webplayback") { + const webplaybackCodec = codecType.codecType as WebplaybackCodecType; // safe cast, zod + const webplaybackResponse = await appleMusicApi.getWebplayback(id); + const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec); + + m3u8Parsed = streamInfo.streamParsed; + streamUrl = streamInfo.streamUrl; + } else { + // TODO: this is unreachable + // typescript doesn't think so + // i think its because of the "let"s why we need this + // fucks up our planned de-dupe on every use of `regularOrWebplayback` + // damn ! + throw new Error("invalid codec type!"); + } + + const ogMp4Name = m3u8Parsed.segments[0].uri; + const ogMp4Url = streamUrl.substring(0, streamUrl.lastIndexOf("/")) + "/" + ogMp4Name; + + const mp4PathParams = new URLSearchParams(); + mp4PathParams.append("id", id); + mp4PathParams.append("originalMp4", ogMp4Url); + mp4PathParams.append("codec", codec); + const mp4Path = "downloadSegment" + "?" + mp4PathParams.toString(); + + const m3u8Text = m3u8Parsed.lines.map((line) => { + if (line.name === "key") { return ""; } + if (line.name === "map") { return line.content.replace(/(URI=")[^"]*(")/, `$1${mp4Path}$2`); } + if (!line.content.startsWith("#")) { return mp4Path; } + return line.content; + }); + + res.setHeader("Content-Type", "application/vnd.apple.mpegurl"); + res.send(m3u8Text.join("\n")); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/web/endpoints/back/download.ts b/src/web/endpoints/back/download.ts index 16b8925..1dbd3d9 100644 --- a/src/web/endpoints/back/download.ts +++ b/src/web/endpoints/back/download.ts @@ -1,5 +1,5 @@ import { getWidevineDecryptionKey } from "../../../downloader/keygen.js"; -import { downloadSong } from "../../../downloader/index.js"; +import { downloadSongFile } from "../../../downloader/index.js"; import express from "express"; import StreamInfo from "../../../downloader/streamInfo.js"; import { appleMusicApi } from "../../../appleMusicApi/index.js"; @@ -7,6 +7,7 @@ import { z } from "zod"; import { validate } from "../../validate.js"; import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js"; import { paths } from "../../openApi.js"; +import { formatSong } from "../../../downloader/format.js"; const router = express.Router(); @@ -31,6 +32,7 @@ paths[path] = { // TODO: support more encryption schemes // TODO: some type of agnostic-ness for the encryption schemes on regular codec +// TODO: now that i think about it.. there gotta be an easier way for the codec type stuff im cryin, like look in dl segment too router.get(path, async (req, res, next) => { try { const { id, codec } = (await validate(req, schema)).query; @@ -45,8 +47,13 @@ router.get(path, async (req, res, next) => { if (streamInfo.widevinePssh !== undefined) { const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); - const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, regularCodec, trackMetadata); - res.download(filePath); + + const filePath = await downloadSongFile(streamInfo.streamUrl, decryptionKey, regularCodec, trackMetadata); + const fileExt = "." + filePath.split(".").at(-1) as string; // safe cast, filePath is always a valid path + const fileName = formatSong(trackAttributes) + fileExt; + + res.attachment(fileName); + res.sendFile(filePath, { root: "." }); } else { throw new Error("no decryption key found for regular codec! this is typical. don't fret!"); } @@ -54,12 +61,18 @@ router.get(path, async (req, res, next) => { const webplaybackCodec = codecType.codecType as WebplaybackCodecType; // safe cast, zod const webplaybackResponse = await appleMusicApi.getWebplayback(id); const trackMetadata = await appleMusicApi.getSong(id); + const trackAttributes = trackMetadata.data[0].attributes; const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec); if (streamInfo.widevinePssh !== undefined) { const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); - const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, webplaybackCodec, trackMetadata); - res.download(filePath); + + const filePath = await downloadSongFile(streamInfo.streamUrl, decryptionKey, webplaybackCodec, trackMetadata); + const fileExt = "." + filePath.split(".").at(-1) as string; // safe cast, filePath is always a valid path + const fileName = formatSong(trackAttributes) + fileExt; + + res.attachment(fileName); + res.sendFile(filePath, { root: "." }); } else { throw new Error("no decryption key found for web playback! this should not happen.."); } diff --git a/src/web/endpoints/back/downloadSegment.ts b/src/web/endpoints/back/downloadSegment.ts new file mode 100644 index 0000000..76acd4d --- /dev/null +++ b/src/web/endpoints/back/downloadSegment.ts @@ -0,0 +1,96 @@ +import express from "express"; +import { paths } from "../../openApi.js"; +import z from "zod"; +import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js"; +import { validate } from "../../validate.js"; +import StreamInfo from "../../../downloader/streamInfo.js"; +import { appleMusicApi } from "../../../appleMusicApi/index.js"; +import { getWidevineDecryptionKey } from "../../../downloader/keygen.js"; +import { fetchAndDecryptStreamSegment } from "../../../downloader/index.js"; + +const router = express.Router(); + +const path = "/downloadSegment"; +const schema = z.object({ + query: z.object({ + id: z.string(), + originalMp4: z.url(), + codec: z.enum([...regularCodecTypeSchema.options, ...webplaybackCodecTypeSchema.options]) + }), + headers: z.object({ + range: z + .string() + .regex(/bytes=\d+-\d+/) + .transform((val) => { + // safe because of regex + const parts = val.split("="); + const values = parts[1].split("-"); + return { + start: parseInt(values[0], 10), + end: parseInt(values[1], 10) + }; + }) + }) +}); + +paths[path] = { + get: { + description: "returns a segment of a song in an mp4 container (see headers, single part byte ranges only) will fail to decrypt if full samples and initialization vectors are not included (and their metadata.) for use with translating m3u8 files that apple provides", + requestParams: { query: schema.shape.query, header: schema.shape.headers }, + responses: { + 200: { description: "returns a segment of a song in an mp4 container" }, + 400: { description: "bad request, invalid query parameters. sent as a zod error with details" }, + default: { description: "upstream api error, or some other error" } + } + } +}; + +// TODO: cache the decryption key for a while, so we don't have to fetch it every time +// the way we could do that is store track id + codec mapped to a decryption key (in memory? for like how long? maybe have an expiry?) +router.get(path, async (req, res, next) => { + try { + const { id, originalMp4, codec } = (await validate(req, schema)).query; + const { range: { start, end } } = (await validate(req, schema)).headers; + + const codecType = new CodecType(codec); + + if (codecType.regularOrWebplayback === "regular") { + const regularCodec = codecType.codecType as RegularCodecType; // safe cast, zod + const trackMetadata = await appleMusicApi.getSong(id); + const trackAttributes = trackMetadata.data[0].attributes; + const streamInfo = await StreamInfo.fromTrackMetadata(trackAttributes, regularCodec); + + if (streamInfo.widevinePssh !== undefined) { + const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); + const file = await fetchAndDecryptStreamSegment(originalMp4, decryptionKey, end - start + 1, start); + + res.setHeader("Content-Type", "application/mp4"); + res.setHeader("Content-Range", `bytes ${start}-${end}/*`); + res.setHeader("Accept-Ranges", "bytes"); + res.status(206).send(file); + } else { + throw new Error("no decryption key found for regular codec! this is typical. don't fret!"); + } + } else if (codecType.regularOrWebplayback === "webplayback") { + const webplaybackCodec = codecType.codecType as WebplaybackCodecType; // safe cast, zod + const webplaybackResponse = await appleMusicApi.getWebplayback(id); + const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec); + + if (streamInfo.widevinePssh !== undefined) { + const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); + const file = await fetchAndDecryptStreamSegment(originalMp4, decryptionKey, end - start + 1, start); + + res.setHeader("Content-Type", "application/mp4"); + res.setHeader("Content-Range", `bytes ${start}-${end}/*`); + res.setHeader("Accept-Ranges", "bytes"); + res.status(206).send(file); + } else { + throw new Error("no decryption key found for web playback! this should not happen.."); + } + } + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/web/endpoints/index.ts b/src/web/endpoints/index.ts index c8cf582..9c3e5b2 100644 --- a/src/web/endpoints/index.ts +++ b/src/web/endpoints/index.ts @@ -8,11 +8,15 @@ export const front = [ ]; import backDownload from "./back/download.js"; +import convertPlaylist from "./back/convertPlaylist.js"; +import downloadSegment from "./back/downloadSegment.js"; import getAlbumMetadata from "./back/getAlbumMetadata.js"; import getPlaylistMetadata from "./back/getPlaylistMetadata.js"; import getTrackMetadata from "./back/getTrackMetadata.js"; export const back = [ backDownload, + convertPlaylist, + downloadSegment, getAlbumMetadata, getPlaylistMetadata, getTrackMetadata