From 4954f84a489368d56c14fd0aae23f89a4ef0d044 Mon Sep 17 00:00:00 2001 From: reidlab Date: Tue, 22 Apr 2025 23:40:21 -0700 Subject: [PATCH] =?UTF-8?q?stre=E1=83=98=E1=83=95am=20=E1=83=A1info=20emos?= =?UTF-8?q?tly=20=E0=B0=B2=E0=B0=BEdone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/appleMusicApi.ts | 9 +- src/downloader/keygen.ts | 4 +- src/downloader/song.ts | 133 ------------------------- src/downloader/streamInfo.ts | 182 +++++++++++++++++++++++++++++++++++ src/index.ts | 4 +- 5 files changed, 193 insertions(+), 139 deletions(-) delete mode 100644 src/downloader/song.ts create mode 100644 src/downloader/streamInfo.ts diff --git a/src/api/appleMusicApi.ts b/src/api/appleMusicApi.ts index 7b151aa..396780d 100644 --- a/src/api/appleMusicApi.ts +++ b/src/api/appleMusicApi.ts @@ -46,7 +46,7 @@ export default class AppleMusicApi { async getWebplayback( trackId: string - ): Promise { + ): Promise { return (await this.http.post(webplaybackApiUrl, { salableAdamId: trackId, language: config.downloader.api.language @@ -57,7 +57,7 @@ export default class AppleMusicApi { trackId: string, trackUri: string, challenge: string - ): Promise<{ license: string | undefined }> { + ): Promise { return (await this.http.post(licenseApiUrl, { challenge: challenge, "key-system": "com.widevine.alpha", @@ -72,3 +72,8 @@ export default class AppleMusicApi { }})).data; } } + +// these are super special types +// i'm not putting this in the ./types folder. +export type WebplaybackResponse = { songList: { assets: { flavor: string, URL: string }[], songId: string }[] }; +export type WidevineLicenseResponse = { license: string | undefined }; diff --git a/src/downloader/keygen.ts b/src/downloader/keygen.ts index 9119e23..c631503 100644 --- a/src/downloader/keygen.ts +++ b/src/downloader/keygen.ts @@ -24,8 +24,8 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")] }); - log.warn("pssh was invalid, treating it as raw data"); - log.warn("this should not throw an error, unless the pssh data is invalid, too"); + 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"); pssh = Buffer.from(rebuiltPssh, "base64"); session = new Session({ privateKey, identifierBlob }, pssh); diff --git a/src/downloader/song.ts b/src/downloader/song.ts deleted file mode 100644 index 9e0877d..0000000 --- a/src/downloader/song.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { appleMusicApi } from "../api/index.js"; -import * as log from "../log.js"; -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 M3u8 = ReturnType; - -// TODO: whole big thing, and a somewhat big issue -// some files can just Not be downloaded -// this is because only the fairplay (com.apple.streamingkeydelivery) key is present -// and no other drm schemes exist.. -// however... there is still widevine ones ???? just tucked away... REALLY WELL -// https://github.com/glomatico/gamdl/blob/main/gamdl/downloader_song_legacy.py#L27 -// bullshit, i tell you. -// havent had this issue with the small pool i tested before late 2024. what ???? -// i don't get it. -// i just tried another thing from 2022 ro 2023 and it worked fine -// SOLVED. widevine keys are not always present in the m3u8 manifest that is default (you can see that in link above, thats why it exists) -// OH. it doesn't seem to give the keys you want anyway LOLLLLLLL???? -// i'm sure its used for *SOMETHING* so i'll keep it - -// SUPER TODO: turn this all into a streaminfo class - -// SUPER TODO: add "legacy", would use stuff from webplayback, default to this -// TODO: make widevine/fairplay optional (esp for above) -async function getStreamInfo(trackMetadata: SongAttributes<["extendedAssetUrls"]>): Promise { - const m3u8Url = trackMetadata.extendedAssetUrls.enhancedHls; - const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); - const m3u8Parsed = hls.default.parse(m3u8.data); - - const drmInfos = getDrmInfos(m3u8Parsed); - const assetInfos = getAssetInfos(m3u8Parsed); - const playlist = await getPlaylist(m3u8Parsed); - const variantId = playlist.properties[0].attributes.stableVariantId; - if (typeof variantId !== "string") { throw "variant id does not exist or is not a string!"; } - const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"]; - - const widevinePssh = getWidevinePssh(drmInfos, drmIds); - const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds); - const fairplayKey = getFairplayKey(drmInfos, drmIds); - - 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)); -} - -type DrmInfos = { [key: string]: { [key: string]: { "URI": string } }; }; -function getDrmInfos(m3u8Data: M3u8): DrmInfos { - // see `getAssetInfos` for the reason why this is so bad - for (const line of m3u8Data.lines) { - if ( - line.name === "sessionData" && - line.content.includes("com.apple.hls.AudioSessionKeyInfo") - ) { - 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")); - } - } - - throw "m3u8 missing audio session key info!"; -} - -// TODO: remove inquery for the codec, including its library, this is for testing -// add a config option for preferred codec ? -// or maybe in the streaminfo function -async function getPlaylist(m3u8Data: M3u8): Promise { - const masterPlaylists = m3u8Data.streamRenditions; - const masterPlaylist = await select({ - message: "codec ?", - choices: masterPlaylists.map((playlist) => ({ - name: playlist.properties[0].attributes.audio as string, - value: playlist - })) - }); - - return masterPlaylist; -} - -// TODO: check type more strictly -// does it really exist? we never check,, -// 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: M3u8): AssetInfos { - // LOL??? THIS LIBRARY IS SO BAD - // YOU CAN'T MAKE THIS SHIT UP - // https://files.catbox.moe/ac0ps4.jpg - for (const line of m3u8Data.lines) { - if ( - line.name === "sessionData" && - line.content.includes("com.apple.hls.audioAssetMetadata") - ) { - 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")); - } - } - - throw "m3u8 missing audio asset metadata!"; -} - -function getDrmData(drmInfos: DrmInfos, drmIds: string[], drmKey: string): string { - const drmInfoEntry = drmIds.find((drmId) => { - const entry = drmInfos[drmId]; - return drmId !== "1" && entry?.[drmKey]; - }); - - if (drmInfoEntry === undefined) { throw `requested drm key (${drmKey}) not found!`; } - - const drmInfo = drmInfos[drmInfoEntry]; - return drmInfo[drmKey].URI; // afaik this index is 100% safe? -} - -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")); -await getStreamInfo((await appleMusicApi.getSong("1615276490")).data[0].attributes); diff --git a/src/downloader/streamInfo.ts b/src/downloader/streamInfo.ts new file mode 100644 index 0000000..bc00416 --- /dev/null +++ b/src/downloader/streamInfo.ts @@ -0,0 +1,182 @@ +import { appleMusicApi } from "../api/index.js"; +import * as log from "../log.js"; +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"; +import type { WebplaybackResponse } from "api/appleMusicApi.js"; + +// ugliest type ever +// this library is so bad +// i wish pain on the person who wrote this /j :smile: +type M3u8 = ReturnType; + +// TODO: whole big thing, and a somewhat big issue +// some files can just Not be downloaded +// this is because only the fairplay (com.apple.streamingkeydelivery) key is present +// and no other drm schemes exist.. +// however... there is still widevine ones ???? just tucked away... REALLY WELL +// https://github.com/glomatico/gamdl/blob/main/gamdl/downloader_song_legacy.py#L27 +// bullshit, i tell you. +// havent had this issue with the small pool i tested before late 2024. what ???? +// i don't get it. +// i just tried another thing from 2022 ro 2023 and it worked fine +// SOLVED. widevine keys are not always present in the m3u8 manifest that is default (you can see that in link above, thats why it exists) +// OH. it doesn't seem to give the keys you want anyway LOLLLLLLL???? +// i'm sure its used for *SOMETHING* so i'll keep it + +export class StreamInfo { + public readonly trackId: string; + public readonly widevinePssh: string | undefined; + public readonly playreadyPssh: string | undefined; + public readonly fairplayKey: string | undefined; + + private constructor( + trackId: string, + widevinePssh: string | undefined, + playreadyPssh: string | undefined, + fairplayKey: string | undefined + ) { + this.trackId = trackId; + this.widevinePssh = widevinePssh; + this.playreadyPssh = playreadyPssh; + this.fairplayKey = fairplayKey; + } + + public static async fromTrackMetadata(trackMetadata: SongAttributes<["extendedAssetUrls"]>): Promise { + log.warn("the track metadata method is experimental, and may not work or give correct values!"); + log.warn("if there is a failure--use a codec that uses the webplayback method"); + + const m3u8Url = trackMetadata.extendedAssetUrls.enhancedHls; + const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); + const m3u8Parsed = hls.default.parse(m3u8.data); + + const drmInfos = getDrmInfos(m3u8Parsed); + const assetInfos = getAssetInfos(m3u8Parsed); + const playlist = await getPlaylist(m3u8Parsed); + const variantId = playlist.properties[0].attributes.stableVariantId; + if (variantId === undefined) { throw "variant id does not exist!"; } + if (typeof variantId !== "string") { throw "variant id is not a string!"; } + const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"]; + + const widevinePssh = getWidevinePssh(drmInfos, drmIds); + const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds); + const fairplayKey = getFairplayKey(drmInfos, drmIds); + + const trackId = trackMetadata.playParams?.id; + if (trackId === undefined) { throw "track id is missing, this may indicate your song isn't accessable w/ your subscription!"; } + + return new StreamInfo( + trackId, + widevinePssh, + playreadyPssh, + fairplayKey + ); + } + + // webplayback is the more "legacy" way + // only supports widevine, from what i can tell + public static async fromWebplayback(webplayback: WebplaybackResponse, flavor: string): Promise { + const song = webplayback.songList[0]; + const asset = song.assets.find((asset) => { return asset.flavor === flavor; }); + if (asset === undefined) { throw "webplayback info for requested flavor doesn't exist!"; } + const trackId = song.songId; + + const m3u8Url = asset.URL; + const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); + const m3u8Parsed = hls.default.parse(m3u8.data); + + const widevinePssh = m3u8Parsed.lines.find((line) => { return line.name === "key"; })?.attributes?.uri; + if (widevinePssh === undefined) { throw "widevine uri is missing!"; } + if (typeof widevinePssh !== "string") { throw "widevine uri is not a string!"; } + + // afaik this ONLY has widevine + return new StreamInfo( + trackId, + widevinePssh, + undefined, + undefined + ); + } +} + +type DrmInfos = { [key: string]: { [key: string]: { "URI": string } }; }; +function getDrmInfos(m3u8Data: M3u8): DrmInfos { + // see `getAssetInfos` for the reason why this is so bad + for (const line of m3u8Data.lines) { + if ( + line.name === "sessionData" && + line.content.includes("com.apple.hls.AudioSessionKeyInfo") + ) { + const value = line.content.match(/VALUE="([^"]+)"/); + if (!value) { throw "could not match for drm key value!"; } + if (!value[1]) { throw "drm key value is empty!"; } + + return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8")); + } + } + + throw "m3u8 missing audio session key info!"; +} + + +type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; } +function getAssetInfos(m3u8Data: M3u8): AssetInfos { + // LOL??? THIS LIBRARY IS SO BAD + // YOU CAN'T MAKE THIS SHIT UP + // https://files.catbox.moe/ac0ps4.jpg + for (const line of m3u8Data.lines) { + if ( + line.name === "sessionData" && + line.content.includes("com.apple.hls.audioAssetMetadata") + ) { + 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")); + } + } + + throw "m3u8 missing audio asset metadata!"; +} + +// SUPER TODO: remove inquery for the codec, including its library, this is for testing +// add a config option for preferred codec ? +// or maybe in the streaminfo function +async function getPlaylist(m3u8Data: M3u8): Promise { + const masterPlaylists = m3u8Data.streamRenditions; + const masterPlaylist = await select({ + message: "codec ?", + choices: masterPlaylists.map((playlist) => ({ + name: playlist.properties[0].attributes.audio as string, + value: playlist + })) + }); + + return masterPlaylist; +} + +function getDrmData(drmInfos: DrmInfos, drmIds: string[], drmKey: string): string | undefined { + const drmInfoEntry = drmIds.find((drmId) => { + const entry = drmInfos[drmId]; + return drmId !== "1" && entry?.[drmKey]; + }); + + if (drmInfoEntry === undefined) { return undefined; } + + const drmInfo = drmInfos[drmInfoEntry]; + return drmInfo[drmKey].URI; +} + +const getWidevinePssh = (drmInfos: DrmInfos, drmIds: string[]): string | undefined => getDrmData(drmInfos, drmIds, widevine); +const getPlayreadyPssh = (drmInfos: DrmInfos, drmIds: string[]): string | undefined => getDrmData(drmInfos, drmIds, playready); +const getFairplayKey = (drmInfos: DrmInfos, drmIds: string[]): string | undefined => getDrmData(drmInfos, drmIds, fairplay); + +// TODO: remove later, this is just for testing +const streamInfo1 = await StreamInfo.fromWebplayback(await appleMusicApi.getWebplayback("1615276490"), "32:ctrp64"); +const streamInfo2 = await StreamInfo.fromTrackMetadata((await appleMusicApi.getSong("1615276490")).data[0].attributes); +if (streamInfo1.widevinePssh !== undefined) { log.debug(await getWidevineDecryptionKey(streamInfo1.widevinePssh, streamInfo1.trackId)); } +if (streamInfo2.widevinePssh !== undefined) { log.debug(await getWidevineDecryptionKey(streamInfo2.widevinePssh, streamInfo2.trackId)); } diff --git a/src/index.ts b/src/index.ts index eee5f47..cec79fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import * as log from "./log.js"; import { appleMusicApi } from "./api/index.js"; class HttpException extends Error { - public status?: number; + public readonly status?: number; constructor(status: number, message: string) { super(message); @@ -71,5 +71,5 @@ process.on("unhandledRejection", (err) => { // TODO: remove later // this is for testing purposes -await import("./downloader/song.js"); +await import("./downloader/streamInfo.js"); await import("./cache.js");