ini𝖔 𝕞𝐚𝔢 b𝖁𝖘𝖋𝖗𝖆𝕊t commit
This commit is contained in:
commit
37729aa76e
26 changed files with 5634 additions and 0 deletions
72
src/api/appleMusicApi.ts
Normal file
72
src/api/appleMusicApi.ts
Normal 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
6
src/api/index.ts
Normal 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
30
src/api/itunesApi.ts
Normal 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
33
src/api/token.ts
Normal 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;
|
||||
}
|
34
src/api/types/appleMusic/attributes.ts
Normal file
34
src/api/types/appleMusic/attributes.ts
Normal 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]>
|
18
src/api/types/appleMusic/extensions.ts
Normal file
18
src/api/types/appleMusic/extensions.ts
Normal 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
|
||||
}
|
||||
}
|
37
src/api/types/appleMusic/extras.ts
Normal file
37
src/api/types/appleMusic/extras.ts
Normal 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
|
||||
}
|
23
src/api/types/appleMusic/responses.ts
Normal file
23
src/api/types/appleMusic/responses.ts
Normal 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
75
src/config.ts
Normal 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 });
|
||||
}
|
654
src/constants/storefrontMappings.ts
Normal file
654
src/constants/storefrontMappings.ts
Normal 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 D’Ivoire",
|
||||
"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
7
src/constants/urls.ts
Normal 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
128
src/downloader/song.ts
Normal 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
73
src/index.ts
Normal 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
92
src/log.ts
Normal 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);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue