diff --git a/README.md b/README.md index a2f9da9..28fb5c4 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ a self-hostable web-ui apple music downloader widevine decryptor with questionab `MEDIA_USER_TOKEN` and `ITUA` are both from your apple music cookies -`WIDEVINE_CLIENT_ID` however... oh boy. this thing kind of Sucks to obtain and i would totally recommend finding a not-so-legal spot you can obtain this from (in fact, i found one on github LOL), rather than extracting it yourself. if you want to do through the pain like i did, check [this guide](forum.videohelp.com/threads/408031-Dumping-Your-own-L3-CDM-with-Android-Studio) out!! once you have your `client_id.bin` file, convert it to base64 and slap it in the env var (`cat client_id.bin | base64 -w 0`) +`WIDEVINE_CLIENT_ID` is uhm owie. this thing kind of Sucks to obtain and i would totally recommend finding a not-so-legal spot you can obtain this from (in fact, i found one on github LOL), rather than extracting it yourself. if you want to do through the pain like i did, check [this guide](forum.videohelp.com/threads/408031-Dumping-Your-own-L3-CDM-with-Android-Studio) out!! once you have your `client_id.bin` file, convert it to base64 and slap it in the env var (`cat client_id.bin | base64 -w 0`) -`WIDEVINE_PRIVATE_KEY` is essentially the same process of obtainment, you'll get it from the same guide!! i'm not sure how to easily find one of these on the web... but i'm sure y'all can pull through. this is also in base64 (`cat private_key.pem | base64 -w 0`) +`WIDEVINE_PRIVATE_KEY` is essentially the same process of obtainment, you'll get it from the same guide!! i'm not sure how to easily find one of these on the web, but i'm sure y'all end users (user count: 0 (TRVTHNVKE)) can pull through. this is also in base64 (`cat private_key.pem | base64 -w 0`) ### config diff --git a/config.example.toml b/config.example.toml index 1d2a9af..230f467 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,12 +1,16 @@ [server] -# can be a port... +# can be a port (int)... # or a unix socket path (e.g. /tmp/sock) port = 2000 [downloader] + +[downloader.cache] # where to store downloaded files (music, lyrics, etc.) # this directory will be created if it does not exist -cache_dir = "cache" +directory = "cache" +# how long to keep downloaded files (in seconds) +ttl = 3600 # (1 hour) [downloader.api] # two letter language code (ISO 639-1) and two letter country code (ISO 3166-1 alpha-2) diff --git a/eslint.config.mjs b/eslint.config.mjs index 6ab4e86..8ad9515 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,8 @@ export default [ // 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 + "@typescript-eslint/explicit-function-return-type": "error", "@typescript-eslint/no-unused-vars": [ "error", diff --git a/src/api/appleMusicApi.ts b/src/api/appleMusicApi.ts index b2d6d64..7b151aa 100644 --- a/src/api/appleMusicApi.ts +++ b/src/api/appleMusicApi.ts @@ -16,19 +16,15 @@ export default class AppleMusicApi { ) { this.storefront = storefront; this.http = axios.create({ - baseURL: ampApiUrl, - headers: { - "Origin": appleMusicHomepageUrl, - "Media-User-Token": mediaUserToken, - // TODO: move somewhere else - // this is only used for `getWidevineLicense` - "x-apple-music-user-token": mediaUserToken, - "x-apple-renewal": true // do i wanna know what this does? - }, - params: { - "l": language - } + baseURL: ampApiUrl }); + + this.http.defaults.headers.common["Origin"] = appleMusicHomepageUrl; + this.http.defaults.headers.common["Media-User-Token"] = mediaUserToken; + // yeah dude. awesome + // https://stackoverflow.com/a/54636780 + this.http.defaults.params = {}; + this.http.defaults.params["l"] = language; } public async login(): Promise { @@ -61,7 +57,7 @@ export default class AppleMusicApi { trackId: string, trackUri: string, challenge: string - ): Promise<{ license: string | undefined }> { // dubious type, doesn't matter much + ): Promise<{ license: string | undefined }> { return (await this.http.post(licenseApiUrl, { challenge: challenge, "key-system": "com.widevine.alpha", @@ -69,6 +65,10 @@ export default class AppleMusicApi { adamId: trackId, isLibrary: false, "user-initiated": true - })).data; + }, { headers: { + // do these do anything. + "x-apple-music-user-token": this.http.defaults.headers.common["Media-User-Token"], + "x-apple-renewal": true + }})).data; } } diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..40b5249 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; +import { config } from "./config.js"; +import * as log from "./log.js"; + +interface CacheEntry { + fileName: string; + expiry: number; +} + +const ttl = config.downloader.cache.ttl * 1000; +const file = path.join(config.downloader.cache.directory, "cache.json"); + +if (!fs.existsSync(config.downloader.cache.directory)) { + log.debug("cache directory not found, creating it"); + fs.mkdirSync(config.downloader.cache.directory, { recursive: true }); +} +if (!fs.existsSync(file)) { + log.debug("cache file not found, creating it"); + fs.writeFileSync(file, JSON.stringify([]), { encoding: "utf-8" }); +} + +// SUPER TODO: implement this diff --git a/src/config.ts b/src/config.ts index 78a1c42..9106e6b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,7 +12,10 @@ const configSchema = z.object({ port: z.number().int().min(0).max(65535).or(z.string()) }), downloader: z.object({ - cache_dir: z.string(), + cache: z.object({ + directory: z.string(), + ttl: z.number().int().min(0) + }), api: z.object({ language: z.string() }) @@ -29,12 +32,13 @@ const envSchema = z.object({ // check that `config.toml` actually exists // if `config.example.toml` doesn't exist(?), error out // if `config.toml` doesn't exist, copy over `comfig.example.toml` to `config.toml` -let defaultConfig = false; +export let defaultConfig = false; if (!fs.existsSync("config.toml")) { if (!fs.existsSync("config.example.toml")) { log.error("config.toml AND config.example.toml not found?? stop this tomfoolery at once"); process.exit(1); } + log.warn("config.toml not found, copying over config.example.toml"); log.warn("using default config; this may result in unexpected behavior!"); fs.copyFileSync("config.example.toml", "config.toml"); defaultConfig = true; @@ -57,7 +61,8 @@ function loadSchemaSomething(schema: T, something: string | // this will make it look (a little) better for the end user if (err instanceof ZodError) { err = fromZodError(err); } - log.error("error loading schema", err); + log.error("error loading schema"); + log.error(err); process.exit(1); } } @@ -66,11 +71,3 @@ export const config = loadSchemaSomething(configSchema, "config.toml"); log.debug("config loaded"); export const env = loadSchemaSomething(envSchema, process.env); log.debug("env loaded"); - -// check that the cache directory exists -// if it doesn't, create it -if (!fs.existsSync(config.downloader.cache_dir)) { - log.debug("cache directory not found, creating it"); - if (defaultConfig) { log.warn("using default config; generated cache directory may not be favorable!");} - fs.mkdirSync(config.downloader.cache_dir, { recursive: true }); -} diff --git a/src/constants/keyFormats.ts b/src/constants/keyFormats.ts new file mode 100644 index 0000000..e849c55 --- /dev/null +++ b/src/constants/keyFormats.ts @@ -0,0 +1,4 @@ +// https://developer.apple.com/documentation/http-live-streaming/using-content-protection-systems-with-hls#Choose-a-key-format +export const widevine = "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"; +export const playready = "com.microsoft.playready"; +export const fairplay = "com.apple.streamingkeydelivery"; diff --git a/src/downloader/keygen.ts b/src/downloader/keygen.ts index 86fe53b..9119e23 100644 --- a/src/downloader/keygen.ts +++ b/src/downloader/keygen.ts @@ -2,11 +2,10 @@ import { LicenseType, Session } from "node-widevine"; import { env } from "../config.js"; import { appleMusicApi } from "../api/index.js"; import { dataUriToBuffer } from "data-uri-to-buffer"; +import psshTools from "pssh-tools"; import * as log from "../log.js"; -import fs from "node:fs"; -import * as psshTools from "pssh-tools"; -export async function getWidevineDecryptionKey(psshDataUri: string, trackId: string): Promise { +export async function getWidevineDecryptionKey(psshDataUri: string, trackId: string): Promise { let pssh = Buffer.from(dataUriToBuffer(psshDataUri).buffer); const privateKey = Buffer.from(env.WIDEVINE_PRIVATE_KEY, "base64"); @@ -18,7 +17,7 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str challenge = session.createLicenseRequest(LicenseType.STREAMING); } catch (err) { // for some reason, if gotten from a webplayback manifest, the pssh is in a completely different format - // well, somewhat. 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({ contentId: "Hiiii", // lol?? i don't know what this is, random slop go!!!! dataOnly: false, @@ -26,7 +25,7 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str }); log.warn("pssh was invalid, treating it as raw data"); - log.warn("this should not error, unless the pssh data is invalid, too"); + log.warn("this should not throw an error, unless the pssh data is invalid, too"); pssh = Buffer.from(rebuiltPssh, "base64"); session = new Session({ privateKey, identifierBlob }, pssh); @@ -39,10 +38,11 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str challenge.toString("base64") ); - if (typeof response?.license !== "string") { throw "license is missing or not a string! sign that authentication failed (unsupported codec?)"; } + if (typeof response?.license !== "string") { throw "license is gone/not a string! sign that auth failed (unsupported codec?)"; } const license = session.parseLicense(Buffer.from(response.license, "base64")); - if (license.length === 0) { throw "license(s) failed to be parsed. this could be an error for invalid data! (e.x. pssh/challenge)"; } + if (license.length === 0) { throw "license(s) failed to be parsed. this could be an error showing invalid data! (e.x. pssh/challenge)"; } - log.info(license); - fs.writeFileSync("license", response.license, { encoding: "utf-8" }); + const validKey = license.find((keyPair) => { return keyPair?.key?.length === 32; })?.key; + if (validKey === undefined) { throw "no valid key found in license"; } + return validKey; } diff --git a/src/downloader/song.ts b/src/downloader/song.ts index 0bb71e4..9e0877d 100644 --- a/src/downloader/song.ts +++ b/src/downloader/song.ts @@ -4,12 +4,13 @@ import type { SongAttributes } from "../api/types/appleMusic/attributes.js"; import hls, { Item } from "parse-hls"; import axios from "axios"; import { getWidevineDecryptionKey } from "./keygen.js"; +import { widevine, playready, fairplay } from "../constants/keyFormats.js"; import { select } from "@inquirer/prompts"; // ugliest type ever // this library is so bad // i wish pain on the person who wrote this /j :smile: -type HLS = ReturnType; +type M3u8 = ReturnType; // TODO: whole big thing, and a somewhat big issue // some files can just Not be downloaded @@ -45,14 +46,16 @@ async function getStreamInfo(trackMetadata: SongAttributes<["extendedAssetUrls"] const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds); const fairplayKey = getFairplayKey(drmInfos, drmIds); - await getWidevineDecryptionKey(widevinePssh, "1615276490"); + const trackId = trackMetadata.playParams?.id; + if (trackId === undefined) { throw "track id is missing, this may indicate your song isn't accessable w/ your subscription!"; } + + // TODO: make this a value in the class when we do that + log.info(await getWidevineDecryptionKey(widevinePssh, trackId)); } -// i don't think i wanna write all of the values we need. annoying ! type DrmInfos = { [key: string]: { [key: string]: { "URI": string } }; }; -function getDrmInfos(m3u8Data: HLS): DrmInfos { +function getDrmInfos(m3u8Data: M3u8): DrmInfos { // see `getAssetInfos` for the reason why this is so bad - // filthy. i should write my own m3u8 library that doesn't suck balls for (const line of m3u8Data.lines) { if ( line.name === "sessionData" && @@ -60,6 +63,7 @@ function getDrmInfos(m3u8Data: HLS): DrmInfos { ) { const value = line.content.match(/VALUE="([^"]+)"/); if (!value) { throw "could not match for value!"; } + if (!value[1]) { throw "value is empty!"; } return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8")); } @@ -70,7 +74,8 @@ function getDrmInfos(m3u8Data: HLS): DrmInfos { // TODO: remove inquery for the codec, including its library, this is for testing // add a config option for preferred codec ? -async function getPlaylist(m3u8Data: HLS): Promise { +// or maybe in the streaminfo function +async function getPlaylist(m3u8Data: M3u8): Promise { const masterPlaylists = m3u8Data.streamRenditions; const masterPlaylist = await select({ message: "codec ?", @@ -85,9 +90,9 @@ async function getPlaylist(m3u8Data: HLS): Promise { // TODO: check type more strictly // does it really exist? we never check,, -// i don't think i wanna write all of the values we need. annoying ! +// filthy. i should write my own m3u8 library that doesn't suck balls type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; } -function getAssetInfos(m3u8Data: HLS): AssetInfos { +function getAssetInfos(m3u8Data: M3u8): AssetInfos { // LOL??? THIS LIBRARY IS SO BAD // YOU CAN'T MAKE THIS SHIT UP // https://files.catbox.moe/ac0ps4.jpg @@ -98,6 +103,7 @@ function getAssetInfos(m3u8Data: HLS): AssetInfos { ) { const value = line.content.match(/VALUE="([^"]+)"/); if (!value) { throw "could not match for value!"; } + if (!value[1]) { throw "value is empty!"; } return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8")); } @@ -118,10 +124,10 @@ function getDrmData(drmInfos: DrmInfos, drmIds: string[], drmKey: string): strin return drmInfo[drmKey].URI; // afaik this index is 100% safe? } -const getWidevinePssh = (drmInfos: DrmInfos, drmIds: string[]): string => getDrmData(drmInfos, drmIds, "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"); -const getPlayreadyPssh = (drmInfos: DrmInfos, drmIds: string[]): string => getDrmData(drmInfos, drmIds, "com.microsoft.playready"); -const getFairplayKey = (drmInfos: DrmInfos, drmIds: string[]): string => getDrmData(drmInfos, drmIds, "com.apple.streamingkeydelivery"); +const getWidevinePssh = (drmInfos: DrmInfos, drmIds: string[]): string => getDrmData(drmInfos, drmIds, widevine); +const getPlayreadyPssh = (drmInfos: DrmInfos, drmIds: string[]): string => getDrmData(drmInfos, drmIds, playready); +const getFairplayKey = (drmInfos: DrmInfos, drmIds: string[]): string => getDrmData(drmInfos, drmIds, fairplay); // TODO: remove later, this is just for testing -log.debug(await appleMusicApi.getWebplayback("1615276490")); +// log.debug(await appleMusicApi.getWebplayback("1615276490")); await getStreamInfo((await appleMusicApi.getSong("1615276490")).data[0].attributes); diff --git a/src/index.ts b/src/index.ts index a317d5d..eee5f47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,8 @@ app.use((err: HttpException, _req: Request, res: Response, _next: NextFunction) // a bit gugly.. await appleMusicApi.login().catch((err) => { - log.error("failed to login to apple music api", err); + log.error("failed to login to apple music api"); + log.error(err); process.exit(1); }); log.debug("logged in to apple music api"); @@ -44,16 +45,16 @@ try { const listener = app.listen(config.server.port, () => { const address = listener.address(); - if (address === null) { - log.error("server is running on unknown address?? unreachable??"); - process.exit(1); - } + // okay, afaik, this is (theoretically) completely unreachable + // if you're listening, you have to have an address + if (address === null) { process.exit(1); } else if (typeof address === "string") { log.info(`hosting on unix://${address}`); } else { log.info(`hosting on http://localhost:${address.port}`); } }); } catch (err) { - log.error("failed to start server", err); + log.error("failed to start server"); + log.error(err); process.exit(1); } @@ -71,3 +72,4 @@ process.on("unhandledRejection", (err) => { // TODO: remove later // this is for testing purposes await import("./downloader/song.js"); +await import("./cache.js"); diff --git a/src/log.ts b/src/log.ts index 974cdaf..1f870e7 100644 --- a/src/log.ts +++ b/src/log.ts @@ -78,15 +78,7 @@ function log(level: Level, ...message: unknown[]): void { process.stdout.write(`${prefix} ${formatted.split("\n").join("\n" + prefix)}\n`); } -export function debug(...message: unknown[]): void { - log(Level.Debug, ...message); -} -export function info(...message: unknown[]): void { - log(Level.Info, ...message); -} -export function warn(...message: unknown[]): void { - log(Level.Warn, ...message); -} -export function error(...message: unknown[]): void { - log(Level.Error, ...message); -} +export function debug(...message: unknown[]): void { log(Level.Debug, ...message); } +export function info(...message: unknown[]): void { log(Level.Info, ...message); } +export function warn(...message: unknown[]): void { log(Level.Warn, ...message); } +export function error(...message: unknown[]): void { log(Level.Error, ...message); }