make릭ع a few f梦nctions have return types + etc
This commit is contained in:
parent
b10801bf29
commit
237ec061d2
11 changed files with 98 additions and 68 deletions
|
@ -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
23
src/cache.ts
Normal 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
|
|
@ -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 });
|
||||
}
|
||||
|
|
4
src/constants/keyFormats.ts
Normal file
4
src/constants/keyFormats.ts
Normal 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";
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
14
src/index.ts
14
src/index.ts
|
@ -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");
|
||||
|
|
16
src/log.ts
16
src/log.ts
|
@ -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); }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue