it mostly works!
This commit is contained in:
parent
76543fd220
commit
44cd13f10c
52 changed files with 879 additions and 396 deletions
|
|
@ -3,11 +3,9 @@ import { spawn } from "node:child_process";
|
|||
import path from "node:path";
|
||||
import { addToCache, isCached } from "../cache.js";
|
||||
|
||||
// TODO: refresh cache timer on download
|
||||
// TODO: remux to m4a?
|
||||
export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodec | WebplaybackCodec): Promise<string> {
|
||||
let baseOutputName = streamUrl.split("/").at(-1)?.split("?").at(0)?.split(".").splice(0, 1).join(".")?.trim();
|
||||
if (!baseOutputName) { throw "could not get base output name from stream url"; }
|
||||
let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1];
|
||||
if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); }
|
||||
baseOutputName += `_${songCodec}`;
|
||||
const encryptedName = baseOutputName + "_enc.mp4";
|
||||
const encryptedPath = path.join(config.downloader.cache.directory, encryptedName);
|
||||
|
|
@ -28,11 +26,14 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son
|
|||
"--paths", config.downloader.cache.directory,
|
||||
"--output", encryptedName,
|
||||
streamUrl
|
||||
]).on("error", (err) => { rej(err); });
|
||||
child.stderr.on("data", (chunk) => { rej(chunk); });
|
||||
]);
|
||||
child.on("error", (err) => { rej(err); });
|
||||
child.stderr.on("data", (data) => { rej(new Error(data.toString().trim())); });
|
||||
child.on("exit", () => { res(); });
|
||||
});
|
||||
|
||||
addToCache(encryptedName);
|
||||
|
||||
await new Promise<void>((res, rej) => {
|
||||
const child = spawn(config.downloader.ffmpeg_path, [
|
||||
"-loglevel", "error",
|
||||
|
|
@ -42,12 +43,12 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son
|
|||
"-c", "copy",
|
||||
"-movflags", "+faststart",
|
||||
decryptedPath
|
||||
]).on("error", (err) => { rej(err); });
|
||||
child.stderr.on("data", (chunk) => { rej(chunk); });
|
||||
]);
|
||||
child.on("error", (err) => { rej(err); });
|
||||
child.stderr.on("data", (data) => { rej(new Error(data.toString().trim())); });
|
||||
child.on("exit", () => { res(); } );
|
||||
});
|
||||
|
||||
addToCache(encryptedName);
|
||||
addToCache(decryptedName);
|
||||
|
||||
return decryptedPath;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { LicenseType, Session } from "node-widevine";
|
||||
import { env } from "../config.js";
|
||||
import { appleMusicApi } from "../api/index.js";
|
||||
import { appleMusicApi } from "../appleMusicApi/index.js";
|
||||
import { dataUriToBuffer } from "data-uri-to-buffer";
|
||||
import psshTools from "pssh-tools";
|
||||
import * as log from "../log.js";
|
||||
|
|
@ -19,7 +19,7 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str
|
|||
// for some reason, if gotten from a webplayback manifest, the pssh is in a completely different format
|
||||
// well, somewhat. it's just the raw data, we have to rebuild the pssh
|
||||
const rebuiltPssh = psshTools.widevine.encodePssh({
|
||||
contentId: "meow", // this actually isn't even needed, but this library is stubborn
|
||||
contentId: "meow", // this actually isn't even needed, but this library is somewhat-stubborn
|
||||
dataOnly: false,
|
||||
keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")]
|
||||
});
|
||||
|
|
@ -38,11 +38,11 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str
|
|||
challenge.toString("base64")
|
||||
);
|
||||
|
||||
if (typeof response?.license !== "string") { throw "license is gone/not a string! sign that auth failed (unsupported codec?)"; }
|
||||
if (typeof response?.license !== "string") { throw new Error("license is gone/not a string! maybe 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 showing invalid data! (e.x. pssh/challenge)"; }
|
||||
if (license.length === 0) { throw new Error("license(s) can't parse. this may be an error showing invalid data! (ex. pssh/challenge)"); }
|
||||
|
||||
const validKey = license.find((keyPair) => { return keyPair?.key?.length === 32; })?.key;
|
||||
if (validKey === undefined) { throw "no valid key found in license"; }
|
||||
if (validKey === undefined) { throw new Error("no valid key found in license!"); }
|
||||
return validKey;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,16 @@
|
|||
import * as log from "../log.js";
|
||||
import type { SongAttributes } from "../api/types/appleMusic/attributes.js";
|
||||
import type { SongAttributes } from "../appleMusicApi/types/attributes.js";
|
||||
import hls, { Item } from "parse-hls";
|
||||
import axios from "axios";
|
||||
import { widevine, playready, fairplay } from "../constants/keyFormats.js";
|
||||
import { songCodecRegex } from "../constants/codecs.js";
|
||||
import type { WebplaybackResponse } from "api/appleMusicApi.js";
|
||||
import type { WebplaybackResponse } from "appleMusicApi/index.js";
|
||||
import { RegularCodec, WebplaybackCodec } from "./index.js";
|
||||
|
||||
// why is this private
|
||||
// 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 default class StreamInfo {
|
||||
public readonly trackId: string;
|
||||
public readonly streamUrl: string;
|
||||
|
|
@ -60,20 +46,22 @@ export default class StreamInfo {
|
|||
const assetInfos = getAssetInfos(m3u8Parsed);
|
||||
const playlist = await getPlaylist(m3u8Parsed, codec);
|
||||
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!"; }
|
||||
if (variantId === undefined) { throw new Error("variant id does not exist!"); }
|
||||
if (typeof variantId !== "string") { throw new Error("variant id is not a string!"); }
|
||||
const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"];
|
||||
|
||||
const correctM3u8Url = m3u8Url.substring(0, m3u8Url.lastIndexOf("/")) + "/" + playlist.uri;
|
||||
|
||||
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!"; }
|
||||
if (trackId === undefined) { throw new Error("track id gone, this may indicate your song isn't accessable w/ your subscription!"); }
|
||||
|
||||
return new StreamInfo(
|
||||
trackId,
|
||||
m3u8Url, // TODO: make this keep in mind the CODEC, yt-dlp will shit itself if not supplied i think
|
||||
correctM3u8Url,
|
||||
widevinePssh,
|
||||
playreadyPssh,
|
||||
fairplayKey
|
||||
|
|
@ -90,7 +78,7 @@ export default class StreamInfo {
|
|||
else if (codec === WebplaybackCodec.AacLegacy) { flavor = "28:ctrp256"; }
|
||||
|
||||
const asset = song.assets.find((asset) => { return asset.flavor === flavor; });
|
||||
if (asset === undefined) { throw "webplayback info for requested flavor doesn't exist!"; }
|
||||
if (asset === undefined) { throw new Error("webplayback info for requested flavor doesn't exist!"); }
|
||||
|
||||
const trackId = song.songId;
|
||||
|
||||
|
|
@ -99,8 +87,8 @@ export default class StreamInfo {
|
|||
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!"; }
|
||||
if (widevinePssh === undefined) { throw new Error("widevine uri is missing!"); }
|
||||
if (typeof widevinePssh !== "string") { throw new Error("widevine uri is not a string!"); }
|
||||
|
||||
// afaik this ONLY has widevine
|
||||
return new StreamInfo(
|
||||
|
|
@ -122,14 +110,14 @@ function getDrmInfos(m3u8Data: M3u8): DrmInfos {
|
|||
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!"; }
|
||||
if (!value) { throw new Error("could not match for drm key value!"); }
|
||||
if (!value[1]) { throw new Error("drm key value is empty!"); }
|
||||
|
||||
return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8"));
|
||||
}
|
||||
}
|
||||
|
||||
throw "m3u8 missing audio session key info!";
|
||||
throw new Error("m3u8 missing audio session key info!");
|
||||
}
|
||||
|
||||
type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; }
|
||||
|
|
@ -143,19 +131,16 @@ function getAssetInfos(m3u8Data: M3u8): AssetInfos {
|
|||
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!"; }
|
||||
if (!value) { throw new Error("could not match for value!"); }
|
||||
if (!value[1]) { throw new Error("value is empty!"); }
|
||||
|
||||
return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8"));
|
||||
}
|
||||
}
|
||||
|
||||
throw "m3u8 missing audio asset metadata!";
|
||||
throw new Error("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, codec: RegularCodec): Promise<Item> {
|
||||
const masterPlaylists = m3u8Data.streamRenditions;
|
||||
const masterPlaylist = masterPlaylists.find((playlist) => {
|
||||
|
|
@ -166,7 +151,7 @@ async function getPlaylist(m3u8Data: M3u8, codec: RegularCodec): Promise<Item> {
|
|||
return match !== null;
|
||||
});
|
||||
|
||||
if (masterPlaylist === undefined) { throw "no master playlist for codec found!"; }
|
||||
if (masterPlaylist === undefined) { throw new Error("no master playlist for codec found!"); }
|
||||
|
||||
return masterPlaylist;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue