streaming!!, oh and file names, linting ig..

This commit is contained in:
Reid 2025-08-15 01:40:21 -07:00
parent 7b15834f17
commit f2800f13c8
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
23 changed files with 1195 additions and 254 deletions

View file

@ -40,8 +40,8 @@ currently you can only get basic widevine ones, everything related to playready
guaranteed formats to work include: guaranteed formats to work include:
- aac-legacy - `aac_legacy`
- aac-he-legacy - `aac_he_legacy`
## screenshots ## screenshots

View file

@ -1,33 +1,55 @@
import typescriptEslint from "typescript-eslint"; import typescriptEslint from "typescript-eslint";
import stylistic from "@stylistic/eslint-plugin";
export default [ export default [
...typescriptEslint.configs.strict,
...typescriptEslint.configs.stylistic,
{
plugins: {
"@typescript-eslint": typescriptEslint.plugin,
"@stylistic": stylistic
}
},
{ {
ignores: [ ignores: [
"**/dist/*", "**/dist/**",
"**/result/*", "**/result/**",
"**/node_modules/*" "**/node_modules/**"
] ]
}, },
...typescriptEslint.configs.strict,
{ {
rules: { rules: {
"quotes": ["error", "double"], "@stylistic/indent": ["error", 4],
"semi": ["error", "always"], "@stylistic/quotes": ["error", "double"],
"comma-dangle": ["error", "never"], "@stylistic/semi": ["error", "always"],
"@stylistic/comma-dangle": ["error", "never"],
// TODO: find a rule to make it so that relative imports are needed // TODO: make imports forced to be dynamic
// 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: find a rule to make seperators on interfaces consistent "@stylistic/member-delimiter-style": [
// it... let's just say... it pmo "error",
{
multilineDetection: "brackets",
multiline: {
delimiter: "semi",
requireLast: true
},
singleline: {
delimiter: "comma",
requireLast: false
}
}
],
"@typescript-eslint/explicit-function-return-type": "error", "@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "error",
{ {
varsIgnorePattern: "^_", varsIgnorePattern: "^_",
argsIgnorePattern: "^_" argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
caughtErrors: "all",
destructuredArrayIgnorePattern: "^_"
} }
] ]
} }

View file

@ -25,7 +25,7 @@
# uncomment this and let the build fail, then get the current hash # uncomment this and let the build fail, then get the current hash
# very scuffed but endorsed! # very scuffed but endorsed!
# npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
npmDepsHash = "sha256-1p/QMZQoFwXxXxlTYTtdHQ3O7p+RSzD3RpoKRB40CHg="; npmDepsHash = "sha256-lvueqcSBjtt9RSMwq2NWCAVT0NrZwDmhEYkjtdOs7js=";
nativeBuildInputs = with pkgs; [ makeWrapper ]; nativeBuildInputs = with pkgs; [ makeWrapper ];
@ -40,7 +40,8 @@
--prefix PATH : ${makeBinPath buildInputs} \ --prefix PATH : ${makeBinPath buildInputs} \
--add-flags "$out/dist/src/index.js" \ --add-flags "$out/dist/src/index.js" \
--set VIEWS_DIR $out/views \ --set VIEWS_DIR $out/views \
--set PUBLIC_DIR $out/public --set PUBLIC_DIR $out/public \
--set NODE_ENV production
runHook postInstall runHook postInstall
''; '';
@ -165,8 +166,8 @@
preStart = '' preStart = ''
config='${cfg.stateDir}/config.toml' config='${cfg.stateDir}/config.toml'
cp -f '${toml.generate "config.toml" cfg.config}' "$config" ln -sf '${toml.generate "config.toml" cfg.config}' "$config"
''; # TODO: symlink instead of cp, shouldn't matter for reproducibility since its preStart but whatever '';
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";

599
package-lock.json generated
View file

@ -29,20 +29,21 @@
"zod-validation-error": "^4.0.1" "zod-validation-error": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/eslint-plugin": "^3.1.0",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/parser": "^7.12.0", "@typescript-eslint/parser": "^7.12.0",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"typescript-eslint": "^7.13.0" "typescript-eslint": "^8.39.1"
} }
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.4.1", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
"integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -393,6 +394,70 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0" "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": { "node_modules/@types/body-parser": {
"version": "1.19.5", "version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@ -520,40 +585,6 @@
"@types/serve-static": "*" "@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": { "node_modules/@typescript-eslint/parser": {
"version": "7.18.0", "version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", "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": { "node_modules/@typescript-eslint/scope-manager": {
"version": "7.18.0", "version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", "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" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "7.18.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz",
"integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==",
"dev": true, "dev": true,
"license": "MIT", "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": { "engines": {
"node": "^18.18.0 || >=20.0.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "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": { "engines": {
"typescript": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"optional": true },
} "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": { "node_modules/@typescript-eslint/types": {
@ -673,26 +841,132 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "7.18.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz",
"integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/types": "7.18.0", "@typescript-eslint/types": "8.39.1",
"@typescript-eslint/typescript-estree": "7.18.0" "@typescript-eslint/typescript-estree": "8.39.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || >=20.0.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "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": { "node_modules/@typescript-eslint/visitor-keys": {
@ -755,9 +1029,9 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -3518,30 +3792,197 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "7.18.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-7.18.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.1.tgz",
"integrity": "sha512-PonBkP603E3tt05lDkbOMyaxJjvKqQrXsnow72sVeOFINDE/qNmnnd+f9b4N+U7W6MXnnYyrhtmF2t08QWwUbA==", "integrity": "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/eslint-plugin": "8.39.1",
"@typescript-eslint/parser": "7.18.0", "@typescript-eslint/parser": "8.39.1",
"@typescript-eslint/utils": "7.18.0" "@typescript-eslint/typescript-estree": "8.39.1",
"@typescript-eslint/utils": "8.39.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || >=20.0.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "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": { "engines": {
"typescript": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"optional": true },
} "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": { "node_modules/uglify-js": {

View file

@ -32,13 +32,14 @@
"zod-validation-error": "^4.0.1" "zod-validation-error": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/eslint-plugin": "^3.1.0",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/parser": "^7.12.0", "@typescript-eslint/parser": "^7.12.0",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"typescript-eslint": "^7.13.0" "typescript-eslint": "^8.39.1"
} }
} }

View file

@ -95,8 +95,8 @@ export default class AppleMusicApi {
U extends RelationshipTypes<T> = ["tracks"] U extends RelationshipTypes<T> = ["tracks"]
> ( > (
term: string, term: string,
limit: number = 25, limit = 25,
offset: number = 0, offset = 0,
extend: T = [] as unknown[] as T, extend: T = [] as unknown[] as T,
relationships: U = ["tracks"] as U relationships: U = ["tracks"] as U
): Promise<SearchResponse<T, U>> { ): Promise<SearchResponse<T, U>> {
@ -118,10 +118,10 @@ export default class AppleMusicApi {
async getWebplayback( async getWebplayback(
trackId: string trackId: string
): Promise<WebplaybackResponse> { ): Promise<WebplaybackResponse> {
// this is one of those endpoints that returns a 200 // no way around this
// no matter what happens, even if theres an error // as we know, we can't have fun things with "WOA" urls
// so we gotta do this stuuuupid hack // https://files.catbox.moe/5oqolg.png (THE LINK WAS CENSORED?? TAKEN DOWN FROM CNN??)
// TODO: find a better way to do this // https://files.catbox.moe/wjxwzk.png
const res = await this.http.post(webplaybackApiUrl, { const res = await this.http.post(webplaybackApiUrl, {
salableAdamId: trackId, salableAdamId: trackId,
language: config.downloader.api.language language: config.downloader.api.language
@ -162,8 +162,8 @@ export default class AppleMusicApi {
names: string | string[], names: string | string[],
relationships: string[], relationships: string[],
extend: string[] extend: string[]
): { [scope: string]: string } { ): Record<string, string> {
const params: { [scope: string]: string } = {}; const params: Record<string, string> = {};
for (const name of Array.isArray(names) ? names : [names]) { for (const name of Array.isArray(names) ? names : [names]) {
for (const relationship of relationships) { params[`include[${name}]`] = relationship; } 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 // these are super special types
// i'm not putting this in the ./types folder. // i'm not putting this in the ./types folder.
// maybe ltr bleh // maybe ltr bleh
export type WebplaybackResponse = { songList: { assets: { flavor: string, URL: string }[], songId: string }[] }; export interface WebplaybackResponse { songList: { assets: { flavor: string, URL: string }[], songId: string }[] };
export type WidevineLicenseResponse = { license: string | undefined }; export interface WidevineLicenseResponse { license: string | undefined };

View file

@ -27,7 +27,7 @@ export async function getToken(baseUrl: string): Promise<string> {
throw new Error("could not find match for the api token in the index javascript file"); 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; return token;
} }

View file

@ -6,68 +6,68 @@ import type {
} from "./extensions.js"; } from "./extensions.js";
export type AlbumAttributes< export type AlbumAttributes<
T extends AlbumAttributesExtensionTypes, T extends AlbumAttributesExtensionTypes
> = { > = {
artistName: string artistName: string;
artwork: Artwork artwork: Artwork;
contentRating?: string contentRating?: string;
copyright?: string copyright?: string;
editorialNotes?: EditorialNotes editorialNotes?: EditorialNotes;
genreNames: string[] genreNames: string[];
isCompilation: boolean isCompilation: boolean;
isComplete: boolean isComplete: boolean;
isMasteredForItunes: boolean isMasteredForItunes: boolean;
isSingle: boolean isSingle: boolean;
name: string name: string;
playParams?: PlayParameters playParams?: PlayParameters;
recordLabel?: string recordLabel?: string;
releaseDate?: string releaseDate?: string;
trackCount: number trackCount: number;
upc?: string upc?: string;
url: string url: string;
} }
& Pick<AlbumAttributesExtensionMap, T[number]> & Pick<AlbumAttributesExtensionMap, T[number]>;
export type PlaylistAttributes< export type PlaylistAttributes<
T extends PlaylistAttributesExtensionTypes, T extends PlaylistAttributesExtensionTypes
> = { > = {
artwork?: Artwork artwork?: Artwork;
curatorName: string curatorName: string;
description?: DescriptionAttribute, description?: DescriptionAttribute;
isChart: boolean, isChart: boolean;
lastModifiedDate?: string lastModifiedDate?: string;
name: string name: string;
playlistType: string playlistType: string;
playParams?: PlayParameters playParams?: PlayParameters;
url: string url: string;
} }
& Pick<PlaylistAttributesExtensionMap, T[number]> & Pick<PlaylistAttributesExtensionMap, T[number]>;
export type SongAttributes< export type SongAttributes<
T extends SongAttributesExtensionTypes, T extends SongAttributesExtensionTypes
> = { > = {
albumName: string albumName: string;
artistName: string artistName: string;
artwork: Artwork artwork: Artwork;
attribution?: string attribution?: string;
composerName?: string composerName?: string;
contentRating?: string contentRating?: string;
discNumber?: number discNumber?: number;
durationInMillis: number durationInMillis: number;
editorialNotes?: EditorialNotes editorialNotes?: EditorialNotes;
genreNames: string[] genreNames: string[];
hasLyrics: boolean hasLyrics: boolean;
isAppleDigitalMaster: boolean isAppleDigitalMaster: boolean;
isrc?: string isrc?: string;
movementCount?: number movementCount?: number;
movementName?: string movementName?: string;
movementNumber?: number movementNumber?: number;
name: string name: string;
playParams?: PlayParameters playParams?: PlayParameters;
previews: Preview[] previews: Preview[];
releaseDate?: string releaseDate?: string;
trackNumber?: number trackNumber?: number;
url: string url: string;
workName?: string workName?: string;
} }
& Pick<SongAttributesExtensionMap, T[number]> & Pick<SongAttributesExtensionMap, T[number]>;

View file

@ -3,27 +3,27 @@ export type AnyAttributesExtensionTypes = AnyAttributesExtensionType[];
export type AlbumAttributesExtensionType = keyof AlbumAttributesExtensionMap; export type AlbumAttributesExtensionType = keyof AlbumAttributesExtensionMap;
export type AlbumAttributesExtensionTypes = AlbumAttributesExtensionType[]; export type AlbumAttributesExtensionTypes = AlbumAttributesExtensionType[];
export type AlbumAttributesExtensionMap = { export interface AlbumAttributesExtensionMap {
artistUrl: string artistUrl: string;
audioVariants?: string[] audioVariants?: string[];
} }
export type PlaylistAttributesExtensionType = keyof PlaylistAttributesExtensionMap; export type PlaylistAttributesExtensionType = keyof PlaylistAttributesExtensionMap;
export type PlaylistAttributesExtensionTypes = PlaylistAttributesExtensionType[]; export type PlaylistAttributesExtensionTypes = PlaylistAttributesExtensionType[];
export type PlaylistAttributesExtensionMap = { export interface PlaylistAttributesExtensionMap {
trackTypes: string[] trackTypes: string[];
} }
export type SongAttributesExtensionType = keyof SongAttributesExtensionMap; export type SongAttributesExtensionType = keyof SongAttributesExtensionMap;
export type SongAttributesExtensionTypes = SongAttributesExtensionType[]; export type SongAttributesExtensionTypes = SongAttributesExtensionType[];
export type SongAttributesExtensionMap = { export interface SongAttributesExtensionMap {
artistUrl: string, artistUrl: string;
audioVariants?: string[], audioVariants?: string[];
extendedAssetUrls: { extendedAssetUrls: {
plus: string, plus: string;
lightweight: string lightweight: string;
superLightweight: string superLightweight: string;
lightweightPlus: string lightweightPlus: string;
enhancedHls: string enhancedHls: string;
} };
} }

View file

@ -1,38 +1,38 @@
// https://developer.apple.com/documentation/applemusicapi/descriptionattribute // https://developer.apple.com/documentation/applemusicapi/descriptionattribute
export interface DescriptionAttribute { export interface DescriptionAttribute {
short?: string short?: string;
standard: string standard: string;
} }
// https://developer.apple.com/documentation/applemusicapi/artwork // https://developer.apple.com/documentation/applemusicapi/artwork
export interface Artwork { export interface Artwork {
bgColor?: string bgColor?: string;
height: number height: number;
width: number width: number;
textColor1?: string textColor1?: string;
textColor2?: string textColor2?: string;
textColor3?: string textColor3?: string;
textColor4?: string textColor4?: string;
url: string url: string;
} }
// https://developer.apple.com/documentation/applemusicapi/editorialnotes // https://developer.apple.com/documentation/applemusicapi/editorialnotes
export interface EditorialNotes { export interface EditorialNotes {
short?: string short?: string;
standard?: string standard?: string;
name?: string name?: string;
tagline?: string tagline?: string;
} }
// https://developer.apple.com/documentation/applemusicapi/playparameters // https://developer.apple.com/documentation/applemusicapi/playparameters
export interface PlayParameters { export interface PlayParameters {
id: string id: string;
kind: string kind: string;
} }
// https://developer.apple.com/documentation/applemusicapi/preview // https://developer.apple.com/documentation/applemusicapi/preview
export interface Preview { export interface Preview {
artwork?: Artwork artwork?: Artwork;
url: string url: string;
hlsUrl?: string hlsUrl?: string;
} }

View file

@ -5,23 +5,23 @@ import type { AlbumAttributes, PlaylistAttributes, SongAttributes } from "./attr
import type { AlbumAttributesExtensionTypes, AnyAttributesExtensionTypes, PlaylistAttributesExtensionTypes, SongAttributesExtensionTypes } from "./extensions.js"; import type { AlbumAttributesExtensionTypes, AnyAttributesExtensionTypes, PlaylistAttributesExtensionTypes, SongAttributesExtensionTypes } from "./extensions.js";
// TODO: have something like this for every resource // TODO: have something like this for every resource
export type Relationship<T> = { export interface Relationship<T> {
href?: string; href?: string;
next?: string; next?: string;
data: { data: {
// TODO: there is extra types here (id, type, etc) i just can't cba to add them lol // TODO: there is extra types here (id, type, etc) i just can't cba to add them lol
// probably not important ! ahahahah // probably not important ! ahahahah
// seems to be the same basic "resource" pattern i'm starting to notice (id(?), href, type, meta (not included), etc) // 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<T extends AnyAttributesExtensionTypes> = keyof RelationshipTypeMap<T>; export type RelationshipType<T extends AnyAttributesExtensionTypes> = keyof RelationshipTypeMap<T>;
export type RelationshipTypes<T extends AnyAttributesExtensionTypes> = RelationshipType<T>[]; export type RelationshipTypes<T extends AnyAttributesExtensionTypes> = RelationshipType<T>[];
export type RelationshipTypeMap<T extends AnyAttributesExtensionTypes> = { export interface RelationshipTypeMap<T extends AnyAttributesExtensionTypes> {
albums: AlbumAttributes<Extract<T, AlbumAttributesExtensionTypes>>, albums: AlbumAttributes<Extract<T, AlbumAttributesExtensionTypes>>;
// TODO: from what i can tell, playlists can NOT be used as a relationship type? kept in case // TODO: from what i can tell, playlists can NOT be used as a relationship type? kept in case
playlists: PlaylistAttributes<Extract<T, PlaylistAttributesExtensionTypes>>, playlists: PlaylistAttributes<Extract<T, PlaylistAttributesExtensionTypes>>;
// TODO: tracks can also be music videos, uh oh. // TODO: tracks can also be music videos, uh oh.
tracks: SongAttributes<Extract<T, SongAttributesExtensionTypes>> tracks: SongAttributes<Extract<T, SongAttributesExtensionTypes>>;
} }

View file

@ -14,18 +14,18 @@ export interface GetAlbumResponse<
> { > {
// https://developer.apple.com/documentation/applemusicapi/albums // https://developer.apple.com/documentation/applemusicapi/albums
data: { data: {
id: string, id: string;
type: "albums", type: "albums";
href: string, href: string;
// https://developer.apple.com/documentation/applemusicapi/albums/attributes-data.dictionary // https://developer.apple.com/documentation/applemusicapi/albums/attributes-data.dictionary
attributes: AlbumAttributes<T>, attributes: AlbumAttributes<T>;
// https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary // https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary
relationships: { relationships: {
[K in U[number]]: Relationship< [K in U[number]]: Relationship<
K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never
> >
} };
}[] }[];
} }
// https://developer.apple.com/documentation/applemusicapi/get-a-catalog-playlist // https://developer.apple.com/documentation/applemusicapi/get-a-catalog-playlist
@ -35,18 +35,18 @@ export interface GetPlaylistResponse<
> { > {
// https://developer.apple.com/documentation/applemusicapi/playlists // https://developer.apple.com/documentation/applemusicapi/playlists
data: { data: {
id: string id: string;
type: "playlists" type: "playlists";
href: string href: string;
// https://developer.apple.com/documentation/applemusicapi/playlists/attributes-data.dictionary // https://developer.apple.com/documentation/applemusicapi/playlists/attributes-data.dictionary
attributes: SongAttributes<T> attributes: SongAttributes<T>;
// https://developer.apple.com/documentation/applemusicapi/playlists/relationships-data.dictionary // https://developer.apple.com/documentation/applemusicapi/playlists/relationships-data.dictionary
relationships: { relationships: {
[K in U[number]]: Relationship< [K in U[number]]: Relationship<
K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never
> >
} };
}[] }[];
} }
// https://developer.apple.com/documentation/applemusicapi/get-a-catalog-song // https://developer.apple.com/documentation/applemusicapi/get-a-catalog-song
@ -56,18 +56,18 @@ export interface GetSongResponse<
> { > {
// https://developer.apple.com/documentation/applemusicapi/songs // https://developer.apple.com/documentation/applemusicapi/songs
data: { data: {
id: string id: string;
type: "songs" type: "songs";
href: string href: string;
// https://developer.apple.com/documentation/applemusicapi/songs/attributes-data.dictionary // https://developer.apple.com/documentation/applemusicapi/songs/attributes-data.dictionary
attributes: SongAttributes<T> attributes: SongAttributes<T>;
// https://developer.apple.com/documentation/applemusicapi/songs/relationships-data.dictionary // https://developer.apple.com/documentation/applemusicapi/songs/relationships-data.dictionary
relationships: { relationships: {
[K in U[number]]: Relationship< [K in U[number]]: Relationship<
K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never
> >
} };
}[] }[];
} }
// TODO: support more than just albums // TODO: support more than just albums
@ -85,19 +85,19 @@ export interface SearchResponse<
albums?: { albums?: {
// https://developer.apple.com/documentation/applemusicapi/albums // https://developer.apple.com/documentation/applemusicapi/albums
data: { data: {
id: string, id: string;
type: "albums", type: "albums";
href: string, href: string;
attributes: AlbumAttributes<Extract<T, AlbumAttributesExtensionTypes>>, attributes: AlbumAttributes<Extract<T, AlbumAttributesExtensionTypes>>;
// https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary // https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary
relationships: { relationships: {
[K in U[number]]: Relationship< [K in U[number]]: Relationship<
K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never
> >
} };
}[], }[];
href?: string, href?: string;
next?: string, next?: string;
} };
} };
} }

View file

@ -57,11 +57,12 @@ function removeCacheEntry(fileName: string): void {
try { try {
fs.unlinkSync(path.join(config.downloader.cache.directory, fileName)); fs.unlinkSync(path.join(config.downloader.cache.directory, fileName));
} catch (err) { } catch (err) {
log.error(`could not remove cache entry ${fileName}`); if ((err as NodeJS.ErrnoException).code === "ENOENT") {
log.error("this could result in 2 effects:"); log.debug(`file for cache entry ${fileName} missing, dropping`);
log.error("1. the cache entry will be removed, and the file never existed, operation is perfect, ignore this"); } else {
log.error("2. the cache entry will be removed, but the file exists, so it will remain in the filesystem"); log.error(`could not remove cache entry ${fileName}!`);
log.error("if you experience the latter, the manual deletion of the file is required to fix this."); log.error(err);
}
} }
} }

View file

@ -1,6 +1,6 @@
import type { RegularCodecType } from "downloader/codecType.js"; import type { RegularCodecType } from "downloader/codecType.js";
export const songCodecRegex: { [key in RegularCodecType]: RegExp } = { export const songCodecRegex: Record<RegularCodecType, RegExp> = {
"aac": /audio-stereo-\d+/, "aac": /audio-stereo-\d+/,
"aac_he": /audio-HE-stereo-\d+/, "aac_he": /audio-HE-stereo-\d+/,
"aac_binaural": /audio-stereo-\d+-binaural/, "aac_binaural": /audio-stereo-\d+-binaural/,

View file

@ -6,7 +6,6 @@ import { pipeline } from "node:stream/promises";
import { addToCache, isCached } from "../cache.js"; import { addToCache, isCached } from "../cache.js";
// TODO: simply add more fields. ha! // TODO: simply add more fields. ha!
// TODO: add album cover
// TODO: add lyrics (what format??) // TODO: add lyrics (what format??)
export class FileMetadata { export class FileMetadata {
public readonly artist: string; public readonly artist: string;
@ -50,7 +49,7 @@ export class FileMetadata {
this.composer = composer; 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 trackAttributes = trackMetadata.data[0].attributes;
const albumAttributes = trackMetadata.data[0].relationships.albums.data[0].attributes; const albumAttributes = trackMetadata.data[0].relationships.albums.data[0].attributes;
@ -102,8 +101,9 @@ export class FileMetadata {
"-i", imagePath, "-i", imagePath,
"-map", "0", "-map", "0",
"-map", "1", "-map", "1",
"-disposition:v", "attached_pic",
"-c:a", "copy", "-c:a", "copy",
"-c:v", "mjpeg" "-c:v", "copy"
]; ];
} }

28
src/downloader/format.ts Normal file
View file

@ -0,0 +1,28 @@
import type { SongAttributes } from "../appleMusicApi/types/attributes.js";
const illegalCharReplacements: Record<string, string> = {
"?": "",
"!": "",
"*": "",
"/": "",
"\\": "",
":": "",
"\"": "",
"<": "",
">": "",
"|": ""
};
// 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}`;
}

View file

@ -5,14 +5,19 @@ import { addToCache, isCached } from "../cache.js";
import type { RegularCodecType, WebplaybackCodecType } from "./codecType.js"; import type { RegularCodecType, WebplaybackCodecType } from "./codecType.js";
import type { GetSongResponse } from "../appleMusicApi/types/responses.js"; import type { GetSongResponse } from "../appleMusicApi/types/responses.js";
import { FileMetadata } from "./fileMetadata.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<string> {
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<string> {
let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1]; let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1];
if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); } if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); }
baseOutputName += `_${songCodec}`; baseOutputName += `_${songCodec}`;
const encryptedName = baseOutputName + "_enc.mp4"; const encryptedName = baseOutputName + "_enc.mp4";
const encryptedPath = path.join(config.downloader.cache.directory, encryptedName); 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); const decryptedPath = path.join(config.downloader.cache.directory, decryptedName);
if ( // TODO: remove check for encrypted file/cache for encrypted? 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; 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<Uint8Array> {
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<IvValue[]> {
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<SampleLocation[]> {
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;
}

View file

@ -15,7 +15,7 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str
let challenge: Buffer; let challenge: Buffer;
try { try {
challenge = session.createLicenseRequest(); challenge = session.createLicenseRequest();
} catch (err) { } catch (_err) {
// for some reason, if gotten from a webplayback manifest, the pssh is in a completely different format // 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 // well, somewhat. it's just the raw data, we have to rebuild the pssh
const rebuiltPssh = psshTools.widevine.encodePssh({ 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")] 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("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"); log.warn("this should not throw an error, unless the pssh data is actually invalid");

View file

@ -14,6 +14,8 @@ type M3u8 = ReturnType<typeof hls.default.parse>;
export default class StreamInfo { export default class StreamInfo {
public readonly trackId: string; public readonly trackId: string;
public readonly streamUrl: string; public readonly streamUrl: string;
public readonly streamParsed: M3u8;
public readonly primaryFileUrl: string;
public readonly widevinePssh: string | undefined; public readonly widevinePssh: string | undefined;
public readonly playreadyPssh: string | undefined; public readonly playreadyPssh: string | undefined;
public readonly fairplayKey: string | undefined; public readonly fairplayKey: string | undefined;
@ -21,20 +23,25 @@ export default class StreamInfo {
private constructor( private constructor(
trackId: string, trackId: string,
streamUrl: string, streamUrl: string,
streamParsed: M3u8,
primaryFileUrl: string,
widevinePssh: string | undefined, widevinePssh: string | undefined,
playreadyPssh: string | undefined, playreadyPssh: string | undefined,
fairplayKey: string | undefined fairplayKey: string | undefined
) { ) {
this.trackId = trackId; this.trackId = trackId;
this.streamUrl = streamUrl; this.streamUrl = streamUrl;
this.streamParsed = streamParsed;
this.primaryFileUrl = primaryFileUrl;
this.widevinePssh = widevinePssh; this.widevinePssh = widevinePssh;
this.playreadyPssh = playreadyPssh; this.playreadyPssh = playreadyPssh;
this.fairplayKey = fairplayKey; 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 :-( // we get a valid key.. but it doesn't work :-(
// upd: it seems thats just how the cookie crumbles. oh well // 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<StreamInfo> { public static async fromTrackMetadata(trackMetadata: SongAttributes<["extendedAssetUrls"]>, codec: RegularCodecType): Promise<StreamInfo> {
log.warn("the track metadata method is experimental, and may not work or give correct values!"); 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"); 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 drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"];
const correctM3u8Url = m3u8Url.substring(0, m3u8Url.lastIndexOf("/")) + "/" + playlist.uri; 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 widevinePssh = getWidevinePssh(drmInfos, drmIds);
const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds); const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds);
@ -63,6 +74,8 @@ export default class StreamInfo {
return new StreamInfo( return new StreamInfo(
trackId, trackId,
correctM3u8Url, correctM3u8Url,
correctM3u8Parsed,
primaryFileUrl,
widevinePssh, widevinePssh,
playreadyPssh, playreadyPssh,
fairplayKey fairplayKey
@ -87,6 +100,8 @@ export default class StreamInfo {
const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); const m3u8 = await axios.get(m3u8Url, { responseType: "text" });
const m3u8Parsed = hls.default.parse(m3u8.data); const m3u8Parsed = hls.default.parse(m3u8.data);
const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed);
const widevinePssh = m3u8Parsed.lines.find((line) => { return line.name === "key"; })?.attributes?.uri; const widevinePssh = m3u8Parsed.lines.find((line) => { return line.name === "key"; })?.attributes?.uri;
if (widevinePssh === undefined) { throw new Error("widevine uri is missing!"); } if (widevinePssh === undefined) { throw new Error("widevine uri is missing!"); }
if (typeof widevinePssh !== "string") { throw new Error("widevine uri is not a string!"); } if (typeof widevinePssh !== "string") { throw new Error("widevine uri is not a string!"); }
@ -95,6 +110,8 @@ export default class StreamInfo {
return new StreamInfo( return new StreamInfo(
trackId, trackId,
m3u8Url, m3u8Url,
m3u8Parsed,
primaryFileUrl,
widevinePssh, widevinePssh,
undefined, undefined,
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<string, Record<string, { "URI": string }>>;;
function getDrmInfos(m3u8Data: M3u8): DrmInfos { function getDrmInfos(m3u8Data: M3u8): DrmInfos {
// see `getAssetInfos` for the reason why this is so bad // see `getAssetInfos` for the reason why this is so bad
for (const line of m3u8Data.lines) { for (const line of m3u8Data.lines) {
@ -121,7 +142,7 @@ function getDrmInfos(m3u8Data: M3u8): DrmInfos {
throw new Error("m3u8 missing audio session key info!"); throw new Error("m3u8 missing audio session key info!");
} }
type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; } type AssetInfos = Record<string, { "AUDIO-SESSION-KEY-IDS": string[] }>;
function getAssetInfos(m3u8Data: M3u8): AssetInfos { function getAssetInfos(m3u8Data: M3u8): AssetInfos {
// LOL??? THIS LIBRARY IS SO BAD // LOL??? THIS LIBRARY IS SO BAD
// YOU CAN'T MAKE THIS SHIT UP // YOU CAN'T MAKE THIS SHIT UP

View file

@ -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<typeof hls.default.parse>;
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;

View file

@ -1,5 +1,5 @@
import { getWidevineDecryptionKey } from "../../../downloader/keygen.js"; import { getWidevineDecryptionKey } from "../../../downloader/keygen.js";
import { downloadSong } from "../../../downloader/index.js"; import { downloadSongFile } from "../../../downloader/index.js";
import express from "express"; import express from "express";
import StreamInfo from "../../../downloader/streamInfo.js"; import StreamInfo from "../../../downloader/streamInfo.js";
import { appleMusicApi } from "../../../appleMusicApi/index.js"; import { appleMusicApi } from "../../../appleMusicApi/index.js";
@ -7,6 +7,7 @@ import { z } from "zod";
import { validate } from "../../validate.js"; import { validate } from "../../validate.js";
import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js"; import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js";
import { paths } from "../../openApi.js"; import { paths } from "../../openApi.js";
import { formatSong } from "../../../downloader/format.js";
const router = express.Router(); const router = express.Router();
@ -31,6 +32,7 @@ paths[path] = {
// TODO: support more encryption schemes // TODO: support more encryption schemes
// TODO: some type of agnostic-ness for the encryption schemes on regular codec // 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) => { router.get(path, async (req, res, next) => {
try { try {
const { id, codec } = (await validate(req, schema)).query; const { id, codec } = (await validate(req, schema)).query;
@ -45,8 +47,13 @@ router.get(path, async (req, res, next) => {
if (streamInfo.widevinePssh !== undefined) { if (streamInfo.widevinePssh !== undefined) {
const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); 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 { } else {
throw new Error("no decryption key found for regular codec! this is typical. don't fret!"); 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 webplaybackCodec = codecType.codecType as WebplaybackCodecType; // safe cast, zod
const webplaybackResponse = await appleMusicApi.getWebplayback(id); const webplaybackResponse = await appleMusicApi.getWebplayback(id);
const trackMetadata = await appleMusicApi.getSong(id); const trackMetadata = await appleMusicApi.getSong(id);
const trackAttributes = trackMetadata.data[0].attributes;
const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec); const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec);
if (streamInfo.widevinePssh !== undefined) { if (streamInfo.widevinePssh !== undefined) {
const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); 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 { } else {
throw new Error("no decryption key found for web playback! this should not happen.."); throw new Error("no decryption key found for web playback! this should not happen..");
} }

View file

@ -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;

View file

@ -8,11 +8,15 @@ export const front = [
]; ];
import backDownload from "./back/download.js"; 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 getAlbumMetadata from "./back/getAlbumMetadata.js";
import getPlaylistMetadata from "./back/getPlaylistMetadata.js"; import getPlaylistMetadata from "./back/getPlaylistMetadata.js";
import getTrackMetadata from "./back/getTrackMetadata.js"; import getTrackMetadata from "./back/getTrackMetadata.js";
export const back = [ export const back = [
backDownload, backDownload,
convertPlaylist,
downloadSegment,
getAlbumMetadata, getAlbumMetadata,
getPlaylistMetadata, getPlaylistMetadata,
getTrackMetadata getTrackMetadata