streივam სinfo emostly లాdone
This commit is contained in:
parent
237ec061d2
commit
4954f84a48
5 changed files with 193 additions and 139 deletions
|
@ -46,7 +46,7 @@ export default class AppleMusicApi {
|
||||||
|
|
||||||
async getWebplayback(
|
async getWebplayback(
|
||||||
trackId: string
|
trackId: string
|
||||||
): Promise<unknown> {
|
): Promise<WebplaybackResponse> {
|
||||||
return (await this.http.post(webplaybackApiUrl, {
|
return (await this.http.post(webplaybackApiUrl, {
|
||||||
salableAdamId: trackId,
|
salableAdamId: trackId,
|
||||||
language: config.downloader.api.language
|
language: config.downloader.api.language
|
||||||
|
@ -57,7 +57,7 @@ export default class AppleMusicApi {
|
||||||
trackId: string,
|
trackId: string,
|
||||||
trackUri: string,
|
trackUri: string,
|
||||||
challenge: string
|
challenge: string
|
||||||
): Promise<{ license: string | undefined }> {
|
): Promise<WidevineLicenseResponse> {
|
||||||
return (await this.http.post(licenseApiUrl, {
|
return (await this.http.post(licenseApiUrl, {
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
"key-system": "com.widevine.alpha",
|
"key-system": "com.widevine.alpha",
|
||||||
|
@ -72,3 +72,8 @@ export default class AppleMusicApi {
|
||||||
}})).data;
|
}})).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 };
|
||||||
|
|
|
@ -24,8 +24,8 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str
|
||||||
keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")]
|
keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")]
|
||||||
});
|
});
|
||||||
|
|
||||||
log.warn("pssh was invalid, treating it as raw data");
|
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 invalid, too");
|
log.warn("this should not throw an error, unless the pssh data is actually invalid");
|
||||||
|
|
||||||
pssh = Buffer.from(rebuiltPssh, "base64");
|
pssh = Buffer.from(rebuiltPssh, "base64");
|
||||||
session = new Session({ privateKey, identifierBlob }, pssh);
|
session = new Session({ privateKey, identifierBlob }, pssh);
|
||||||
|
|
|
@ -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<typeof hls.default.parse>;
|
|
||||||
|
|
||||||
// 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<void> {
|
|
||||||
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<Item> {
|
|
||||||
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);
|
|
182
src/downloader/streamInfo.ts
Normal file
182
src/downloader/streamInfo.ts
Normal file
|
@ -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<typeof hls.default.parse>;
|
||||||
|
|
||||||
|
// 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<StreamInfo> {
|
||||||
|
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<StreamInfo> {
|
||||||
|
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<Item> {
|
||||||
|
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)); }
|
|
@ -5,7 +5,7 @@ import * as log from "./log.js";
|
||||||
import { appleMusicApi } from "./api/index.js";
|
import { appleMusicApi } from "./api/index.js";
|
||||||
|
|
||||||
class HttpException extends Error {
|
class HttpException extends Error {
|
||||||
public status?: number;
|
public readonly status?: number;
|
||||||
|
|
||||||
constructor(status: number, message: string) {
|
constructor(status: number, message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
@ -71,5 +71,5 @@ process.on("unhandledRejection", (err) => {
|
||||||
|
|
||||||
// TODO: remove later
|
// TODO: remove later
|
||||||
// this is for testing purposes
|
// this is for testing purposes
|
||||||
await import("./downloader/song.js");
|
await import("./downloader/streamInfo.js");
|
||||||
await import("./cache.js");
|
await import("./cache.js");
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue