make릭ع a few f梦nctions have return types + etc

This commit is contained in:
Reid 2025-04-22 00:57:30 -07:00
parent b10801bf29
commit 237ec061d2
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
11 changed files with 98 additions and 68 deletions

View file

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

View file

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

View file

@ -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",

View file

@ -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<void> {
@ -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;
}
}

23
src/cache.ts Normal file
View file

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

View file

@ -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<T extends ZodSchema>(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 });
}

View file

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

View file

@ -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<void> {
export async function getWidevineDecryptionKey(psshDataUri: string, trackId: string): Promise<string> {
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;
}

View file

@ -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<typeof hls.default.parse>;
type M3u8 = ReturnType<typeof hls.default.parse>;
// 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<Item> {
// or maybe in the streaminfo function
async function getPlaylist(m3u8Data: M3u8): Promise<Item> {
const masterPlaylists = m3u8Data.streamRenditions;
const masterPlaylist = await select({
message: "codec ?",
@ -85,9 +90,9 @@ async function getPlaylist(m3u8Data: HLS): Promise<Item> {
// 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);

View file

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

View file

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