ini𝖔 𝕞𝐚𝔢 b𝖁𝖘𝖋𝖗𝖆𝕊t commit

This commit is contained in:
Reid 2025-04-18 00:55:39 -07:00
commit 37729aa76e
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
26 changed files with 5634 additions and 0 deletions

72
src/api/appleMusicApi.ts Normal file
View file

@ -0,0 +1,72 @@
import axios, { type AxiosInstance } from "axios";
import { ampApiUrl, appleMusicHomepageUrl, licenseApiUrl, webplaybackApiUrl } from "../constants/urls.js";
import type { GetSongResponse } from "./types/appleMusic/responses.js";
import type { SongAttributesExtensionTypes } from "./types/appleMusic/extensions.js";
import { getToken } from "./token.js";
import { config } from "../config.js";
export default class AppleMusicApi {
private storefront: string;
private http: AxiosInstance;
public constructor(
storefront: string,
language: string,
mediaUserToken: string
) {
this.storefront = storefront;
this.http = axios.create({
baseURL: ampApiUrl,
headers: {
"Media-User-Token": mediaUserToken,
"Origin": appleMusicHomepageUrl
},
params: {
"l": language
}
});
}
public async login(): Promise<void> {
this.http.defaults.headers["Authorization"] = `Bearer ${await getToken(appleMusicHomepageUrl)}`;
}
async getSong<
T extends SongAttributesExtensionTypes = ["extendedAssetUrls"]
> (
id: string,
extend: T = ["extendedAssetUrls"] as T
): Promise<GetSongResponse<T>> {
return (await this.http.get<GetSongResponse<T>>(`/v1/catalog/${this.storefront}/songs/${id}`, {
params: {
extend: extend.join(",")
}
})).data;
}
async getWebplayback(
trackId: string
): Promise<unknown> {
return (await this.http.post(webplaybackApiUrl, {
salableAdamId: trackId,
language: config.downloader.api.language
})).data;
}
async getWidevineLicense(
trackId: string,
trackUri: string,
challenge: string
): Promise<string> {
return (await this.http.post(licenseApiUrl, {
params: {
challenge: challenge,
"key-system": "com.widevine.alpha",
uri: trackUri,
adamId: trackId,
isLibrary: false,
"user-initiated": true
}
})).data;
}
}

6
src/api/index.ts Normal file
View file

@ -0,0 +1,6 @@
import { config, env } from "../config.js";
import AppleMusicApi from "./appleMusicApi.js";
import ItunesApi from "./itunesApi.js";
export const appleMusicApi = new AppleMusicApi(env.ITUA, config.downloader.api.language, env.MEDIA_USER_TOKEN);
export const itunesApi = new ItunesApi(env.ITUA, config.downloader.api.language);

30
src/api/itunesApi.ts Normal file
View file

@ -0,0 +1,30 @@
import axios, { type AxiosInstance } from "axios";
import storefrontMappings from "../constants/storefrontMappings.js";
export default class ItunesApi {
private storefront: string;
private language: string;
private http: AxiosInstance;
public constructor(
storefront: string,
language: string
) {
const storefrontCode = storefrontMappings.find((storefrontMapping) => storefrontMapping.code.toLowerCase() === storefront.toLowerCase())?.storefrontId;
if (!storefrontCode) { throw new Error(`failed matching storefront id for storefront: ${storefront}`); }
this.storefront = storefront;
this.language = language;
this.http = axios.create({
headers: {
// this SUCKSSSS lmao
// why did apple do that
"X-Apple-Store-Front": storefrontCode
},
params: {
"country": storefront,
"lang": language
}
});
}
}

33
src/api/token.ts Normal file
View file

@ -0,0 +1,33 @@
import * as log from "../log.js";
// basically, i don't want to pay 100 dollars for a dev token to the official API
// here's the kicker--this token is more "privileged"
// thanks to this guy complaining to apple for telling us this! https://developer.apple.com/forums/thread/702228
// apple says "any other method may be blocked at any time" (posted in mar 2022, not happening)
export async function getToken(baseUrl: string): Promise<string> {
const indexResponse = await fetch(baseUrl);
const indexBody = await indexResponse.text();
const jsRegex = /\/assets\/index-legacy-[^/]+\.js/;
const jsPath = indexBody.match(jsRegex)?.[0];
if (!jsPath) {
throw new Error("could not match for the index javascript file");
}
const jsResponse = await fetch(baseUrl + jsPath);
const jsBody = await jsResponse.text();
// the token is actually a base64-encoded JWT
// `eyJh` === `{"a`, which is the beginning of a JWT (a is the start of alg)
const tokenRegex = /eyJh([^"]*)/;
const token = jsBody.match(tokenRegex)?.[0];
if (!token) {
throw new Error("could not find match for the api token in the index javascript file");
}
log.debug("got token");
return token;
}

View file

@ -0,0 +1,34 @@
import type { SongAttributesExtensionMap, SongAttributesExtensionTypes } from "./extensions.js";
import type { Artwork, EditorialNotes, PlayParameters, Preview } from "./extras.js";
// import type { SongAttributesRelationshipMap, SongAttributesRelationshipTypes } from "./relationships.js";
export type SongAttributes<
T extends SongAttributesExtensionTypes,
// U extends SongAttributesRelationshipTypes
> = {
albumName: string
artistName: string
artwork: Artwork
attribution?: string
composerName?: string
contentRating?: string
discNumber?: number
durationInMillis: number
editorialNotes?: EditorialNotes
genreNames: string[]
hasLyrics: boolean
isAppleDigitalMaster: boolean
isrc?: string
movementCount?: number
movementName?: string
movementNumber?: number
name: string
playParams?: PlayParameters
previews: Preview[]
releaseDate?: string
trackNumber?: number
url: string
workName?: string
}
& Pick<SongAttributesExtensionMap, T[number]>
// & Pick<SongAttributesRelationshipMap, U[number]>

View file

@ -0,0 +1,18 @@
export type AnyAttributesExtentionType = SongAttributesExtensionMap;
export type SongAttributesExtensionType = keyof SongAttributesExtensionMap;
export type SongAttributesExtensionTypes = SongAttributesExtensionType[];
export type SongAttributesExtensionMap = {
artistUrl: string,
// does not seem to work
// is documented though,, will leave optional?
audioVariants?: string[],
// undocumented !! awesome !!
extendedAssetUrls: {
plus: string,
lightweight: string
superLightweight: string
lightweightPlus: string
enhancedHls: string
}
}

View file

@ -0,0 +1,37 @@
// TODO: i can't cba to make views (what r these??) / relationships 100% type safe
// it's difficult because they seem to trickle down extensions, too
// oh wait--the relationships are not always present (not applicable) so thats even better !!
// so i would have to make a type for each relationship hahahahahahaha
// https://developer.apple.com/documentation/applemusicapi/artwork
export interface Artwork {
bgColor?: string
height: number
width: number
textColor1?: string
textColor2?: string
textColor3?: string
textColor4?: string
url: string
}
// https://developer.apple.com/documentation/applemusicapi/editorialnotes
export interface EditorialNotes {
short?: string
standard?: string
name?: string
tagline?: string
}
// https://developer.apple.com/documentation/applemusicapi/playparameters
export interface PlayParameters {
id: string
kind: string
}
// https://developer.apple.com/documentation/applemusicapi/preview
export interface Preview {
artwork?: Artwork
url: string
hlsUrl?: string
}

View file

@ -0,0 +1,23 @@
// TODO: relationships
// this is a semi-reverse engineered type definition for the apple music API
// i don't, and never will, support [scoping parameters](https://developer.apple.com/documentation/applemusicapi/handling-resource-representation-and-relationships#Scoping-Parameters)
// there is a small chance i will add more endpoints
import type { SongAttributes } from "./attributes.js";
import type { SongAttributesExtensionTypes } from "./extensions.js";
// https://developer.apple.com/documentation/applemusicapi/get-a-catalog-song
export interface GetSongResponse<
T extends SongAttributesExtensionTypes,
> {
// https://developer.apple.com/documentation/applemusicapi/songs
data: {
id: string
type: "songs"
href: string
// https://developer.apple.com/documentation/applemusicapi/songs/attributes-data.dictionary
attributes: SongAttributes<T>
// TODO: add relationships
// https://developer.apple.com/documentation/applemusicapi/songs/relationships-data.dictionary
}[]
}

75
src/config.ts Normal file
View file

@ -0,0 +1,75 @@
import fs from "node:fs";
import * as log from "./log.js";
import toml from "toml";
import { z, ZodError, ZodSchema } from "zod";
import * as dotenv from "dotenv";
import { fromZodError } from "zod-validation-error";
dotenv.config();
const configSchema = z.object({
server: z.object({
port: z.number().int().min(0).max(65535).or(z.string())
}),
downloader: z.object({
cache_dir: z.string(),
api: z.object({
language: z.string()
})
})
});
const envSchema = z.object({
MEDIA_USER_TOKEN: z.string(),
ITUA: z.string(),
WIDEVINE_CLIENT_ID: z.string()
});
// 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;
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("using default config; this may result in unexpected behavior!");
fs.copyFileSync("config.example.toml", "config.toml");
defaultConfig = true;
}
/**
* @param schema the zod schema to use
* @param something the thing to load the schema from--either a **file path to a toml file** or **an object** (e.g. process.env)
* @returns the inferred type of the schema
*/
function loadSchemaSomething<T extends ZodSchema>(schema: T, something: string | unknown): z.infer<T> {
try {
if (typeof something === "string") {
return schema.parse(toml.parse(fs.readFileSync(something, "utf-8")));
} else {
return schema.parse(something);
}
} catch (err) {
// zod errors are kind of Ugly by default
// 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);
process.exit(1);
}
}
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,654 @@
// thank u goat
// https://gist.github.com/BrychanOdlum/2208578ba151d1d7c4edeeda15b4e9b1
export default [
{
"name": "Algeria",
"code": "DZ",
"storefrontId": 143563
},
{
"name": "Angola",
"code": "AO",
"storefrontId": 143564
},
{
"name": "Anguilla",
"code": "AI",
"storefrontId": 143538
},
{
"name": "Antigua & Barbuda",
"code": "AG",
"storefrontId": 143540
},
{
"name": "Argentina",
"code": "AR",
"storefrontId": 143505
},
{
"name": "Armenia",
"code": "AM",
"storefrontId": 143524
},
{
"name": "Australia",
"code": "AU",
"storefrontId": 143460
},
{
"name": "Austria",
"code": "AT",
"storefrontId": 143445
},
{
"name": "Azerbaijan",
"code": "AZ",
"storefrontId": 143568
},
{
"name": "Bahrain",
"code": "BH",
"storefrontId": 143559
},
{
"name": "Bangladesh",
"code": "BD",
"storefrontId": 143490
},
{
"name": "Barbados",
"code": "BB",
"storefrontId": 143541
},
{
"name": "Belarus",
"code": "BY",
"storefrontId": 143565
},
{
"name": "Belgium",
"code": "BE",
"storefrontId": 143446
},
{
"name": "Belize",
"code": "BZ",
"storefrontId": 143555
},
{
"name": "Bermuda",
"code": "BM",
"storefrontId": 143542
},
{
"name": "Bolivia",
"code": "BO",
"storefrontId": 143556
},
{
"name": "Botswana",
"code": "BW",
"storefrontId": 143525
},
{
"name": "Brazil",
"code": "BR",
"storefrontId": 143503
},
{
"name": "British Virgin Islands",
"code": "VG",
"storefrontId": 143543
},
{
"name": "Brunei",
"code": "BN",
"storefrontId": 143560
},
{
"name": "Bulgaria",
"code": "BG",
"storefrontId": 143526
},
{
"name": "Canada",
"code": "CA",
"storefrontId": 143455
},
{
"name": "Cayman Islands",
"code": "KY",
"storefrontId": 143544
},
{
"name": "Chile",
"code": "CL",
"storefrontId": 143483
},
{
"name": "China",
"code": "CN",
"storefrontId": 143465
},
{
"name": "Colombia",
"code": "CO",
"storefrontId": 143501
},
{
"name": "Costa Rica",
"code": "CR",
"storefrontId": 143495
},
{
"name": "Cote DIvoire",
"code": "CI",
"storefrontId": 143527
},
{
"name": "Croatia",
"code": "HR",
"storefrontId": 143494
},
{
"name": "Cyprus",
"code": "CY",
"storefrontId": 143557
},
{
"name": "Czech Republic",
"code": "CZ",
"storefrontId": 143489
},
{
"name": "Denmark",
"code": "DK",
"storefrontId": 143458
},
{
"name": "Dominica",
"code": "DM",
"storefrontId": 143545
},
{
"name": "Dominican Rep.",
"code": "DO",
"storefrontId": 143508
},
{
"name": "Ecuador",
"code": "EC",
"storefrontId": 143509
},
{
"name": "Egypt",
"code": "EG",
"storefrontId": 143516
},
{
"name": "El Salvador",
"code": "SV",
"storefrontId": 143506
},
{
"name": "Estonia",
"code": "EE",
"storefrontId": 143518
},
{
"name": "Finland",
"code": "FI",
"storefrontId": 143447
},
{
"name": "France",
"code": "FR",
"storefrontId": 143442
},
{
"name": "Germany",
"code": "DE",
"storefrontId": 143443
},
{
"name": "Ghana",
"code": "GH",
"storefrontId": 143573
},
{
"name": "Greece",
"code": "GR",
"storefrontId": 143448
},
{
"name": "Grenada",
"code": "GD",
"storefrontId": 143546
},
{
"name": "Guatemala",
"code": "GT",
"storefrontId": 143504
},
{
"name": "Guyana",
"code": "GY",
"storefrontId": 143553
},
{
"name": "Honduras",
"code": "HN",
"storefrontId": 143510
},
{
"name": "Hong Kong",
"code": "HK",
"storefrontId": 143463
},
{
"name": "Hungary",
"code": "HU",
"storefrontId": 143482
},
{
"name": "Iceland",
"code": "IS",
"storefrontId": 143558
},
{
"name": "India",
"code": "IN",
"storefrontId": 143467
},
{
"name": "Indonesia",
"code": "ID",
"storefrontId": 143476
},
{
"name": "Ireland",
"code": "IE",
"storefrontId": 143449
},
{
"name": "Israel",
"code": "IL",
"storefrontId": 143491
},
{
"name": "Italy",
"code": "IT",
"storefrontId": 143450
},
{
"name": "Jamaica",
"code": "JM",
"storefrontId": 143511
},
{
"name": "Japan",
"code": "JP",
"storefrontId": 143462
},
{
"name": "Jordan",
"code": "JO",
"storefrontId": 143528
},
{
"name": "Kazakstan",
"code": "KZ",
"storefrontId": 143517
},
{
"name": "Kenya",
"code": "KE",
"storefrontId": 143529
},
{
"name": "Korea, Republic Of",
"code": "KR",
"storefrontId": 143466
},
{
"name": "Kuwait",
"code": "KW",
"storefrontId": 143493
},
{
"name": "Latvia",
"code": "LV",
"storefrontId": 143519
},
{
"name": "Lebanon",
"code": "LB",
"storefrontId": 143497
},
{
"name": "Liechtenstein",
"code": "LI",
"storefrontId": 143522
},
{
"name": "Lithuania",
"code": "LT",
"storefrontId": 143520
},
{
"name": "Luxembourg",
"code": "LU",
"storefrontId": 143451
},
{
"name": "Macau",
"code": "MO",
"storefrontId": 143515
},
{
"name": "Macedonia",
"code": "MK",
"storefrontId": 143530
},
{
"name": "Madagascar",
"code": "MG",
"storefrontId": 143531
},
{
"name": "Malaysia",
"code": "MY",
"storefrontId": 143473
},
{
"name": "Maldives",
"code": "MV",
"storefrontId": 143488
},
{
"name": "Mali",
"code": "ML",
"storefrontId": 143532
},
{
"name": "Malta",
"code": "MT",
"storefrontId": 143521
},
{
"name": "Mauritius",
"code": "MU",
"storefrontId": 143533
},
{
"name": "Mexico",
"code": "MX",
"storefrontId": 143468
},
{
"name": "Moldova, Republic Of",
"code": "MD",
"storefrontId": 143523
},
{
"name": "Montserrat",
"code": "MS",
"storefrontId": 143547
},
{
"name": "Nepal",
"code": "NP",
"storefrontId": 143484
},
{
"name": "Netherlands",
"code": "NL",
"storefrontId": 143452
},
{
"name": "New Zealand",
"code": "NZ",
"storefrontId": 143461
},
{
"name": "Nicaragua",
"code": "NI",
"storefrontId": 143512
},
{
"name": "Niger",
"code": "NE",
"storefrontId": 143534
},
{
"name": "Nigeria",
"code": "NG",
"storefrontId": 143561
},
{
"name": "Norway",
"code": "NO",
"storefrontId": 143457
},
{
"name": "Oman",
"code": "OM",
"storefrontId": 143562
},
{
"name": "Pakistan",
"code": "PK",
"storefrontId": 143477
},
{
"name": "Panama",
"code": "PA",
"storefrontId": 143485
},
{
"name": "Paraguay",
"code": "PY",
"storefrontId": 143513
},
{
"name": "Peru",
"code": "PE",
"storefrontId": 143507
},
{
"name": "Philippines",
"code": "PH",
"storefrontId": 143474
},
{
"name": "Poland",
"code": "PL",
"storefrontId": 143478
},
{
"name": "Portugal",
"code": "PT",
"storefrontId": 143453
},
{
"name": "Qatar",
"code": "QA",
"storefrontId": 143498
},
{
"name": "Romania",
"code": "RO",
"storefrontId": 143487
},
{
"name": "Russia",
"code": "RU",
"storefrontId": 143469
},
{
"name": "Saudi Arabia",
"code": "SA",
"storefrontId": 143479
},
{
"name": "Senegal",
"code": "SN",
"storefrontId": 143535
},
{
"name": "Serbia",
"code": "RS",
"storefrontId": 143500
},
{
"name": "Singapore",
"code": "SG",
"storefrontId": 143464
},
{
"name": "Slovakia",
"code": "SK",
"storefrontId": 143496
},
{
"name": "Slovenia",
"code": "SI",
"storefrontId": 143499
},
{
"name": "South Africa",
"code": "ZA",
"storefrontId": 143472
},
{
"name": "Spain",
"code": "ES",
"storefrontId": 143454
},
{
"name": "Sri Lanka",
"code": "LK",
"storefrontId": 143486
},
{
"name": "St. Kitts & Nevis",
"code": "KN",
"storefrontId": 143548
},
{
"name": "St. Lucia",
"code": "LC",
"storefrontId": 143549
},
{
"name": "St. Vincent & The Grenadines",
"code": "VC",
"storefrontId": 143550
},
{
"name": "Suriname",
"code": "SR",
"storefrontId": 143554
},
{
"name": "Sweden",
"code": "SE",
"storefrontId": 143456
},
{
"name": "Switzerland",
"code": "CH",
"storefrontId": 143459
},
{
"name": "Taiwan",
"code": "TW",
"storefrontId": 143470
},
{
"name": "Tanzania",
"code": "TZ",
"storefrontId": 143572
},
{
"name": "Thailand",
"code": "TH",
"storefrontId": 143475
},
{
"name": "The Bahamas",
"code": "BS",
"storefrontId": 143539
},
{
"name": "Trinidad & Tobago",
"code": "TT",
"storefrontId": 143551
},
{
"name": "Tunisia",
"code": "TN",
"storefrontId": 143536
},
{
"name": "Turkey",
"code": "TR",
"storefrontId": 143480
},
{
"name": "Turks & Caicos",
"code": "TC",
"storefrontId": 143552
},
{
"name": "Uganda",
"code": "UG",
"storefrontId": 143537
},
{
"name": "UK",
"code": "GB",
"storefrontId": 143444
},
{
"name": "Ukraine",
"code": "UA",
"storefrontId": 143492
},
{
"name": "United Arab Emirates",
"code": "AE",
"storefrontId": 143481
},
{
"name": "Uruguay",
"code": "UY",
"storefrontId": 143514
},
{
"name": "USA",
"code": "US",
"storefrontId": 143441
},
{
"name": "Uzbekistan",
"code": "UZ",
"storefrontId": 143566
},
{
"name": "Venezuela",
"code": "VE",
"storefrontId": 143502
},
{
"name": "Vietnam",
"code": "VN",
"storefrontId": 143471
},
{
"name": "Yemen",
"code": "YE",
"storefrontId": 143571
}
];

7
src/constants/urls.ts Normal file
View file

@ -0,0 +1,7 @@
export const appleMusicHomepageUrl = "https://beta.music.apple.com";
export const ampApiUrl = "https://amp-api.music.apple.com";
export const webplaybackApiUrl = "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback";
export const licenseApiUrl = "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense";
export const itunesLookupApiUrl = "https://itunes.apple.com/lookup";
export const itunesPageApiUrl = "https://music.apple.com";

128
src/downloader/song.ts Normal file
View file

@ -0,0 +1,128 @@
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";
// TODO: remove
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>;
// 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
// SUPER TODO: turn this all into a streaminfo class
// this typing is dubious...
// TODO: possibly just stop using an array; use union type on generic
// TODO: add "legacy" fallback
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);
log.debug("widevine pssh", widevinePssh);
log.debug("playready pssh", playreadyPssh);
log.debug("fairplay key", fairplayKey);
}
// 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 {
// 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" &&
line.content.includes("com.apple.hls.AudioSessionKeyInfo")
) {
const value = line.content.match(/VALUE="([^"]+)"/);
if (!value) { throw "could not match for value!"; }
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 ?
async function getPlaylist(m3u8Data: HLS): 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,,
// i don't think i wanna write all of the values we need. annoying !
type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; }
function getAssetInfos(m3u8Data: HLS): 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!"; }
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, "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");
// TODO: remove later, this is just for testing
log.debug(await appleMusicApi.getWebplayback("1758429584"));
await getStreamInfo((await appleMusicApi.getSong("1758429584")).data[0].attributes);

73
src/index.ts Normal file
View file

@ -0,0 +1,73 @@
import { config } from "./config.js";
import express, { type NextFunction, type Request, type Response } from "express";
import process from "node:process";
import * as log from "./log.js";
import { appleMusicApi } from "./api/index.js";
class HttpException extends Error {
public status?: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.message = message;
}
}
const app = express();
app.use((req, _res, next) => {
next(new HttpException(404, `${req.path} not found`));
});
app.use((err: HttpException, _req: Request, res: Response, _next: NextFunction) => {
if (!err.status || err.status % 500 < 100) {
log.error(err);
}
const status = err.status ?? 500;
const message = err.message;
res.status(status).send(message);
});
// TODO: reorganize this
// a bit gugly..
await appleMusicApi.login().catch((err) => {
log.error("failed to login to apple music api", err);
process.exit(1);
});
log.debug("logged in to apple music api");
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);
}
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);
process.exit(1);
}
process.on("uncaughtException", (err) => {
log.error("uncaught exception!");
log.error(err);
process.exit(1);
});
process.on("unhandledRejection", (err) => {
log.error("unhandled rejection!");
log.error(err);
process.exit(1);
});
// TODO: remove later
// this is for testing purposes
await import("./downloader/song.js");

92
src/log.ts Normal file
View file

@ -0,0 +1,92 @@
import chalk from "chalk";
import util from "node:util";
import path from "node:path";
import process from "node:process";
import callsites from "callsites";
import sourceMapSupport from "source-map-support";
import { fileURLToPath } from "node:url";
// https://en.wikipedia.org/wiki/Trollface#/media/File:Trollface.png
// TODO: register this the first thing in the app?
sourceMapSupport.install();
enum Level {
Debug,
Info,
Warn,
Error
}
const levelColors = {
[Level.Debug]: chalk.blue,
[Level.Info]: chalk.green,
[Level.Warn]: chalk.yellow,
[Level.Error]: chalk.red
};
const levelNames = {
[Level.Debug]: "DEBUG",
[Level.Info]: "INFO",
[Level.Warn]: "WARN",
[Level.Error]: "ERROR"
};
function timePrefix(): string {
const now = new Date();
return chalk.gray(now.toISOString());
}
function stackPrefix(): string {
// little tidbit: this does not work on *some* engines (v8 stack format)
// i think bun will work, i think deno will not
const frame = sourceMapSupport.wrapCallSite(callsites()[3] as sourceMapSupport.CallSite);
const file = frame.getFileName();
const line = frame.getLineNumber();
const column = frame.getColumnNumber();
if (file === null || line === null || column === null) { return chalk.gray("unknown caller!"); }
const filePatched = `${path.relative(process.cwd(), fileURLToPath(file))}`;
return chalk.gray(`${filePatched}:${line}:${column}`);
}
function levelPrefix(level: Level): string {
const highestLevelLength = Math.max(...Object.values(levelNames).map(n => n.length));
const name = levelNames[level];
const color = levelColors[level];
return color(name.padStart(highestLevelLength));
}
function format(thing: unknown): string {
if (typeof thing === "string") {
return thing;
} else if (thing instanceof Error) {
return thing.stack || thing.toString();
} else {
// set a decently high depth
// this is so we can see zod errors in their entirety, for example
return util.inspect(thing, { colors: true, depth: 10 });
}
}
function log(level: Level, ...message: unknown[]): void {
const formatted = message
.map(m => format(m))
.reduce((l, r) => l.includes("\n") || r.includes("\n") ? l + "\n" + r : l + " " + r, "")
.trim();
const prefix = `${timePrefix()} ${levelPrefix(level)} ${stackPrefix()}`;
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);
}