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

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