axios->undici, reducing overhead and vuln

This commit is contained in:
Reid 2025-10-13 00:11:39 -07:00
parent a3cefee49a
commit 3a179e0c69
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
12 changed files with 160 additions and 232 deletions

142
package-lock.json generated
View file

@ -11,7 +11,6 @@
"dependencies": { "dependencies": {
"@libsql/client": "^0.15.12", "@libsql/client": "^0.15.12",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"axios": "^1.11.0",
"callsites": "^4.2.0", "callsites": "^4.2.0",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"data-uri-to-buffer": "^6.0.2", "data-uri-to-buffer": "^6.0.2",
@ -28,6 +27,7 @@
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"toml": "^3.0.0", "toml": "^3.0.0",
"undici": "^7.16.0",
"zod": "^4.0.14", "zod": "^4.0.14",
"zod-openapi": "^5.3.0", "zod-openapi": "^5.3.0",
"zod-validation-error": "^4.0.1" "zod-validation-error": "^4.0.1"
@ -2326,23 +2326,6 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@ -2558,18 +2541,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/compress-commons": { "node_modules/compress-commons": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
@ -2782,15 +2753,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -3059,21 +3021,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.9", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@ -3664,26 +3611,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -3700,22 +3627,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/format-duration": { "node_modules/format-duration": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/format-duration/-/format-duration-3.0.2.tgz", "resolved": "https://registry.npmjs.org/format-duration/-/format-duration-3.0.2.tgz",
@ -3992,21 +3903,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -4473,27 +4369,6 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -4911,12 +4786,6 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pssh-tools": { "node_modules/pssh-tools": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pssh-tools/-/pssh-tools-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pssh-tools/-/pssh-tools-1.2.0.tgz",
@ -5899,6 +5768,15 @@
"node": ">=0.8.0" "node": ">=0.8.0"
} }
}, },
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.20.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",

View file

@ -17,7 +17,6 @@
"dependencies": { "dependencies": {
"@libsql/client": "^0.15.12", "@libsql/client": "^0.15.12",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"axios": "^1.11.0",
"callsites": "^4.2.0", "callsites": "^4.2.0",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"data-uri-to-buffer": "^6.0.2", "data-uri-to-buffer": "^6.0.2",
@ -34,6 +33,7 @@
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"toml": "^3.0.0", "toml": "^3.0.0",
"undici": "^7.16.0",
"zod": "^4.0.14", "zod": "^4.0.14",
"zod-openapi": "^5.3.0", "zod-openapi": "^5.3.0",
"zod-validation-error": "^4.0.1" "zod-validation-error": "^4.0.1"

View file

@ -1,4 +1,3 @@
import axios, { type AxiosInstance } from "axios";
import { ampApiUrl, appleMusicHomepageUrl, licenseApiUrl, webplaybackApiUrl } from "../constants/urls.js"; import { ampApiUrl, appleMusicHomepageUrl, licenseApiUrl, webplaybackApiUrl } from "../constants/urls.js";
import type { GetAlbumResponse, GetPlaylistResponse, GetSongResponse, SearchResponse } from "./types/responses.js"; import type { GetAlbumResponse, GetPlaylistResponse, GetSongResponse, SearchResponse } from "./types/responses.js";
import type { AlbumAttributesExtensionTypes, AnyAttributesExtensionTypes, SongAttributesExtensionTypes } from "./types/extensions.js"; import type { AlbumAttributesExtensionTypes, AnyAttributesExtensionTypes, SongAttributesExtensionTypes } from "./types/extensions.js";
@ -6,10 +5,12 @@ import { getToken } from "./token.js";
import { config, env } from "../config.js"; import { config, env } from "../config.js";
import { HttpException } from "../web/index.js"; import { HttpException } from "../web/index.js";
import type { RelationshipTypes } from "./types/relationships.js"; import type { RelationshipTypes } from "./types/relationships.js";
import { fetch, request } from "undici";
export default class AppleMusicApi { export default class AppleMusicApi {
private storefront: string; private storefront: string;
private http: AxiosInstance; private headers: Headers;
private params: URLSearchParams;
public constructor( public constructor(
storefront: string, storefront: string,
@ -17,21 +18,58 @@ export default class AppleMusicApi {
mediaUserToken: string mediaUserToken: string
) { ) {
this.storefront = storefront; this.storefront = storefront;
this.http = axios.create({
baseURL: ampApiUrl
});
this.http.defaults.headers.common["Origin"] = appleMusicHomepageUrl; this.headers = new Headers();
this.http.defaults.headers.common["Media-User-Token"] = mediaUserToken; this.headers.set("Origin", appleMusicHomepageUrl);
this.headers.set("Media-User-Token", mediaUserToken);
this.headers.set("x-apple-music-user-token", mediaUserToken);
this.headers.set("x-apple-renewal", "true");
// yeah dude. awesome this.params = new URLSearchParams();
// https://stackoverflow.com/a/54636780 this.params.set("l", language);
this.http.defaults.params = {};
this.http.defaults.params["l"] = language;
} }
public async login(): Promise<void> { public async login(): Promise<void> {
this.http.defaults.headers.common["Authorization"] = `Bearer ${await getToken(appleMusicHomepageUrl)}`; this.headers.set("Authorization", `Bearer ${await getToken(appleMusicHomepageUrl)}`);
}
// TODO: dedupe these functions
// TODO: also make their param/body/header stuff more modular
// please!!!!!
private async get<
T
> (link: string, params: Record<string, string | number | boolean> = {}): Promise<T> {
const url = new URL(link);
const urlParams = new URLSearchParams(this.params);
for (const entry of Object.entries(params)) { urlParams.set(entry[0], entry[1].toString()); }
url.search = urlParams.toString();
const response = await request(url, { headers: this.headers });
const json = await response.body.json();
return json as T;
}
// TODO: discover why it works when its fetch but not request
// what i mean by "works" is it doesn't return an error upstream that i can't replicate in cURL
// i'm so confused mannnn
private async post<
T
> (link: string, data: Record<string, string | number | boolean> = {}): Promise<T> {
const url = new URL(link);
const urlParams = new URLSearchParams(this.params);
url.search = urlParams.toString();
const headers = new Headers(this.headers);
headers.set("Content-Type", "application/json");
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(data),
headers: headers
});
const json = await response.json();
return json as T;
} }
async getAlbum< async getAlbum<
@ -42,12 +80,10 @@ export default class AppleMusicApi {
extend: T = [] as unknown[] as T, extend: T = [] as unknown[] as T,
relationships: U = ["tracks"] as U relationships: U = ["tracks"] as U
): Promise<GetAlbumResponse<T, U>> { ): Promise<GetAlbumResponse<T, U>> {
return (await this.http.get<GetAlbumResponse<T, U>>(`/v1/catalog/${this.storefront}/albums/${id}`, { return await this.get<GetAlbumResponse<T, U>>(`${ampApiUrl}/v1/catalog/${this.storefront}/albums/${id}`, {
params: {
extend: extend.join(","), extend: extend.join(","),
include: relationships.join(",") include: relationships.join(",")
} });
})).data;
} }
// TODO: make it so you can get more than the first 100 tracks // TODO: make it so you can get more than the first 100 tracks
@ -61,12 +97,10 @@ export default class AppleMusicApi {
extend: T = [] as never as T, extend: T = [] as never as T,
relationships: U = ["tracks"] as U relationships: U = ["tracks"] as U
): Promise<GetPlaylistResponse<T, U>> { ): Promise<GetPlaylistResponse<T, U>> {
return (await this.http.get<GetPlaylistResponse<T, U>>(`/v1/catalog/${this.storefront}/playlists/${id}`, { return await this.get<GetPlaylistResponse<T, U>>(`${ampApiUrl}/v1/catalog/${this.storefront}/playlists/${id}`, {
params: {
extend: extend.join(","), extend: extend.join(","),
include: relationships.join(",") include: relationships.join(",")
} });
})).data;
} }
async getSong< async getSong<
@ -80,12 +114,10 @@ export default class AppleMusicApi {
extend: T = ["extendedAssetUrls"] as T, extend: T = ["extendedAssetUrls"] as T,
relationships: U = ["albums"] as U relationships: U = ["albums"] as U
): Promise<GetSongResponse<T, U>> { ): Promise<GetSongResponse<T, U>> {
return (await this.http.get<GetSongResponse<T, U>>(`/v1/catalog/${this.storefront}/songs/${id}`, { return await this.get<GetSongResponse<T, U>>(`${ampApiUrl}/v1/catalog/${this.storefront}/songs/${id}`, {
params: {
extend: extend.join(","), extend: extend.join(","),
include: relationships.join(",") include: relationships.join(",")
} });
})).data;
} }
// TODO: add support for other types / abstract it for other types // TODO: add support for other types / abstract it for other types
@ -100,8 +132,7 @@ export default class AppleMusicApi {
extend: T = [] as unknown[] as T, extend: T = [] as unknown[] as T,
relationships: U = ["tracks"] as U relationships: U = ["tracks"] as U
): Promise<SearchResponse<T, U>> { ): Promise<SearchResponse<T, U>> {
return (await this.http.get(`/v1/catalog/${this.storefront}/search`, { return await this.get(`${ampApiUrl}/v1/catalog/${this.storefront}/search`, {
params: {
...this.addScopingParameters("albums", relationships, extend), ...this.addScopingParameters("albums", relationships, extend),
...{ ...{
term: term, term: term,
@ -111,8 +142,7 @@ export default class AppleMusicApi {
extend: extend.join(","), extend: extend.join(","),
include: relationships.join(",") include: relationships.join(",")
} }
} });
})).data;
} }
async getWebplayback( async getWebplayback(
@ -122,18 +152,18 @@ export default class AppleMusicApi {
// as we know, we can't have fun things with "WOA" urls // as we know, we can't have fun things with "WOA" urls
// https://files.catbox.moe/5oqolg.png (THE LINK WAS CENSORED?? TAKEN DOWN FROM CNN??) // https://files.catbox.moe/5oqolg.png (THE LINK WAS CENSORED?? TAKEN DOWN FROM CNN??)
// https://files.catbox.moe/wjxwzk.png // https://files.catbox.moe/wjxwzk.png
const res = await this.http.post(webplaybackApiUrl, { const res = await this.post<WebplaybackResponse & { failureType?: string }>(webplaybackApiUrl, {
salableAdamId: trackId, salableAdamId: trackId,
language: config.downloader.api.language language: config.downloader.api.language
}); });
if (res.data?.failureType === "3077") { if (res?.failureType === "3077") {
throw new HttpException(404, "track not found"); throw new HttpException(404, "track not found");
} else if (res.data?.failureType !== undefined) { } else if (res?.failureType !== undefined) {
throw new HttpException(500, `upstream webplayback api error: ${res.data.failureType}`); throw new HttpException(500, `upstream webplayback api error: ${res.failureType}`);
} }
return res.data; return res;
} }
async getWidevineLicense( async getWidevineLicense(
@ -141,19 +171,14 @@ export default class AppleMusicApi {
trackUri: string, trackUri: string,
challenge: string challenge: string
): Promise<WidevineLicenseResponse> { ): Promise<WidevineLicenseResponse> {
return (await this.http.post(licenseApiUrl, { return (await this.post(licenseApiUrl, {
challenge: challenge, challenge: challenge,
"key-system": "com.widevine.alpha", "key-system": "com.widevine.alpha",
uri: trackUri, uri: trackUri,
adamId: trackId, adamId: trackId,
isLibrary: false, isLibrary: false,
"user-initiated": true "user-initiated": true
}, { headers: { }));
// do these do anything.
// i'm including them anyway,,
"x-apple-music-user-token": this.http.defaults.headers.common["Media-User-Token"],
"x-apple-renewal": true
}})).data;
} }
// helper function to automatically add scoping parameters // helper function to automatically add scoping parameters

View file

@ -1,3 +1,4 @@
import { request } from "undici";
import * as log from "../log.js"; import * as log from "../log.js";
// basically, i don't want to pay 100 dollars for a dev token to the official API // basically, i don't want to pay 100 dollars for a dev token to the official API
@ -5,8 +6,8 @@ import * as log from "../log.js";
// thanks to this guy complaining to apple for telling us this! https://developer.apple.com/forums/thread/702228 // 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, most likely not happening) // apple says "any other method may be blocked at any time" (posted in mar 2022, most likely not happening)
export async function getToken(baseUrl: string): Promise<string> { export async function getToken(baseUrl: string): Promise<string> {
const indexResponse = await fetch(baseUrl); const indexResponse = await request(baseUrl);
const indexBody = await indexResponse.text(); const indexBody = await indexResponse.body.text();
const jsRegex = /\/assets\/index-legacy-[^/]+\.js/; const jsRegex = /\/assets\/index-legacy-[^/]+\.js/;
const jsPath = indexBody.match(jsRegex)?.[0]; const jsPath = indexBody.match(jsRegex)?.[0];
@ -15,8 +16,8 @@ export async function getToken(baseUrl: string): Promise<string> {
throw new Error("could not match for the index javascript file!"); throw new Error("could not match for the index javascript file!");
} }
const jsResponse = await fetch(baseUrl + jsPath); const jsResponse = await request(baseUrl + jsPath);
const jsBody = await jsResponse.text(); const jsBody = await jsResponse.body.text();
// the token is actually a base64-encoded JWT // the token is actually a base64-encoded JWT
// `eyJh` === `{"a`, which is the beginning of a JWT (a is the start of alg) // `eyJh` === `{"a`, which is the beginning of a JWT (a is the start of alg)

View file

@ -9,6 +9,7 @@ import { createDecipheriv } from "node:crypto";
import type { AlbumAttributes } from "../appleMusicApi/types/attributes.js"; import type { AlbumAttributes } from "../appleMusicApi/types/attributes.js";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs";
import { request } from "undici";
export async function downloadSongFile(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType, songResponse: GetSongResponse<[], ["albums"]>): Promise<string> { export async function downloadSongFile(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType, songResponse: GetSongResponse<[], ["albums"]>): Promise<string> {
let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1]; let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1];
@ -59,8 +60,8 @@ export async function downloadSongFile(streamUrl: string, decryptionKey: string,
// TODO: less mem alloc/access // TODO: less mem alloc/access
// TODO: use actual atom scanning. what if the magic bytes appear in a sample // TODO: use actual atom scanning. what if the magic bytes appear in a sample
export async function fetchAndDecryptStreamSegment(segmentUrl: string, decryptionKey: string, fetchLength: number, offset: number): Promise<Uint8Array> { export async function fetchAndDecryptStreamSegment(segmentUrl: string, decryptionKey: string, fetchLength: number, offset: number): Promise<Uint8Array> {
const response = await fetch(segmentUrl, { headers: { "range": `bytes=${offset}-${offset + fetchLength - 1}` }}); const response = await request(segmentUrl, { headers: { "range": `bytes=${offset}-${offset + fetchLength - 1}` }});
const file = new Uint8Array(await response.arrayBuffer()); const file = new Uint8Array(await response.body.bytes());
// this translates to "moof" // this translates to "moof"
const moof = new Uint8Array([0x6D, 0x6F, 0x6F, 0x66]); const moof = new Uint8Array([0x6D, 0x6F, 0x6F, 0x66]);
@ -121,12 +122,9 @@ export async function downloadAlbumCover(albumAttributes: AlbumAttributes<[]>):
const imagePath = path.join(config.downloader.cache.directory, imageFileName); const imagePath = path.join(config.downloader.cache.directory, imageFileName);
if (await isFileCached(imageFileName) === false) { if (await isFileCached(imageFileName) === false) {
const response = await fetch(url); const response = await request(url);
if (!response.ok) { throw new Error(`failed to fetch artwork: ${response.status}`); } await pipeline(response.body, createWriteStream(imagePath));
if (!response.body) { throw new Error("no response body for artwork!"); }
await pipeline(response.body as ReadableStream, createWriteStream(imagePath));
await addFileToCache(imageFileName); await addFileToCache(imageFileName);
} }

View file

@ -1,11 +1,11 @@
import * as log from "../log.js"; import * as log from "../log.js";
import type { SongAttributes } from "../appleMusicApi/types/attributes.js"; import type { SongAttributes } from "../appleMusicApi/types/attributes.js";
import hls, { Item } from "parse-hls"; import hls, { Item } from "parse-hls";
import axios from "axios";
import { widevine, playready, fairplay } from "../constants/keyFormats.js"; import { widevine, playready, fairplay } from "../constants/keyFormats.js";
import { songCodecRegex } from "../constants/codecs.js"; import { songCodecRegex } from "../constants/codecs.js";
import type { WebplaybackResponse } from "appleMusicApi/index.js"; import type { WebplaybackResponse } from "appleMusicApi/index.js";
import type { RegularCodecType, WebplaybackCodecType } from "./codecType.js"; import type { RegularCodecType, WebplaybackCodecType } from "./codecType.js";
import { request } from "undici";
// why is this private // why is this private
// i wish pain on the person who wrote this /j :smile: // i wish pain on the person who wrote this /j :smile:
@ -47,8 +47,8 @@ export default class StreamInfo {
log.warn("if there is a failure--use a codec that uses the webplayback method"); log.warn("if there is a failure--use a codec that uses the webplayback method");
const m3u8Url = trackMetadata.extendedAssetUrls.enhancedHls; const m3u8Url = trackMetadata.extendedAssetUrls.enhancedHls;
const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); const m3u8 = await request(m3u8Url);
const m3u8Parsed = hls.default.parse(m3u8.data); const m3u8Parsed = hls.default.parse(await m3u8.body.text());
const drmInfos = getDrmInfos(m3u8Parsed); const drmInfos = getDrmInfos(m3u8Parsed);
const assetInfos = getAssetInfos(m3u8Parsed); const assetInfos = getAssetInfos(m3u8Parsed);
@ -59,8 +59,8 @@ export default class StreamInfo {
const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"]; const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"];
const correctM3u8Url = m3u8Url.substring(0, m3u8Url.lastIndexOf("/")) + "/" + playlist.uri; const correctM3u8Url = m3u8Url.substring(0, m3u8Url.lastIndexOf("/")) + "/" + playlist.uri;
const correctM3u8 = await axios.get(correctM3u8Url, { responseType: "text" }); const correctM3u8 = await request(correctM3u8Url);
const correctM3u8Parsed = hls.default.parse(correctM3u8.data); const correctM3u8Parsed = hls.default.parse(await correctM3u8.body.text());
const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed); const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed);
@ -97,8 +97,8 @@ export default class StreamInfo {
const trackId = song.songId; const trackId = song.songId;
const m3u8Url = asset.URL; const m3u8Url = asset.URL;
const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); const m3u8 = await request(m3u8Url);
const m3u8Parsed = hls.default.parse(m3u8.data); const m3u8Parsed = hls.default.parse(await m3u8.body.text());
const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed); const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed);

View file

@ -3,14 +3,33 @@ import process from "node:process";
import * as log from "./log.js"; import * as log from "./log.js";
import { appleMusicApi } from "./appleMusicApi/index.js"; import { appleMusicApi } from "./appleMusicApi/index.js";
import { app } from "./web/index.js"; import { app } from "./web/index.js";
// @ts-expect-error: cacheStores should exist--it does not
// TODO: file an issue on undici and remove this when fixed
// DONE: issue filed... https://github.com/nodejs/undici/issues/4614
// OKAY: it was "resolved". just have to wait for the next release
import { Agent, interceptors, setGlobalDispatcher, cacheStores } from "undici";
await appleMusicApi.login().catch((err) => { setGlobalDispatcher(new Agent().compose([
log.error("failed to login to apple music api"); interceptors.responseError(),
interceptors.redirect(),
interceptors.decompress(),
// TODO: configurable cache sizes?
// these values are pretty nice for non-binary (lol) data
interceptors.cache({ store: new cacheStores.MemoryCacheStore({
maxSize: 50 * 1024 * 1024, // 5mb
maxCount: 1000,
maxEntrySize: 5 * 1024 // 5kb
})})
]));
try {
await appleMusicApi.login();
log.info("logged in to apple music api");
} catch (err) {
log.error("failed to login to apple music api!");
log.error(err); log.error(err);
process.exit(1); process.exit(1);
}).finally(() => { }
log.info("logged in to apple music api");
});
try { try {
const listener = app.listen(config.server.port, () => { const listener = app.listen(config.server.port, () => {
@ -24,18 +43,7 @@ try {
else { log.info(`hosting on http://localhost:${address.port}`); } else { log.info(`hosting on http://localhost:${address.port}`); }
}); });
} catch (err) { } catch (err) {
log.error("failed to start server"); log.error("failed to start server!");
log.error(err); log.error(err);
process.exit(1); 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);
});

View file

@ -89,3 +89,21 @@ export function debug(...message: unknown[]): void { log(Level.Debug, ...message
export function info(...message: unknown[]): void { log(Level.Info, ...message); } export function info(...message: unknown[]): void { log(Level.Info, ...message); }
export function warn(...message: unknown[]): void { log(Level.Warn, ...message); } export function warn(...message: unknown[]): void { log(Level.Warn, ...message); }
export function error(...message: unknown[]): void { log(Level.Error, ...message); } export function error(...message: unknown[]): void { log(Level.Error, ...message); }
// custom interceptors for uncaught exceptions and unhandled rejections
// additionally, also wrap warnings (but, make the experimental warning shut up)
process.on("uncaughtException", (err) => {
error("uncaught exception!");
error(err);
process.exit(1);
});
process.on("unhandledRejection", (err) => {
error("unhandled rejection!");
error(err);
process.exit(1);
});
process.removeAllListeners("warning");
process.on("warning", (err) => {
if (err.name === "ExperimentalWarning") { return; }
warn(err);
});

View file

@ -53,10 +53,11 @@ router.get(path, async (req, res, next) => {
} }
const decryptionKey = const decryptionKey =
await getKeyFromCache(id, codecType.codecType) || await getKeyFromCache(id, codecType.codecType) ??
await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
await addKeyToCache(id, codecType.codecType, decryptionKey); await addKeyToCache(id, codecType.codecType, decryptionKey);
// TODO: stream to user
const filePath = await downloadSongFile(streamInfo.streamUrl, decryptionKey, codecType.codecType, trackMetadata); const filePath = await downloadSongFile(streamInfo.streamUrl, decryptionKey, codecType.codecType, trackMetadata);
const fileExt = "." + filePath.split(".").at(-1) as string; // safe cast, filePath is always a valid path const fileExt = "." + filePath.split(".").at(-1) as string; // safe cast, filePath is always a valid path
const fileName = formatSongForFs(trackAttributes) + fileExt; const fileName = formatSongForFs(trackAttributes) + fileExt;

View file

@ -67,7 +67,7 @@ router.get(path, async (req, res, next) => {
} }
const decryptionKey = const decryptionKey =
await getKeyFromCache(trackId, codecType.codecType) || await getKeyFromCache(trackId, codecType.codecType) ??
await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
await addKeyToCache(trackId, codecType.codecType, decryptionKey); await addKeyToCache(trackId, codecType.codecType, decryptionKey);

View file

@ -46,8 +46,6 @@ paths[path] = {
} }
}; };
// TODO: cache the decryption key for a while, so we don't have to fetch it every time
// the way we could do that is store track id + codec mapped to a decryption key (in memory? for like how long? maybe have an expiry?)
router.get(path, async (req, res, next) => { router.get(path, async (req, res, next) => {
try { try {
const { id, originalMp4, codec } = (await validate(req, schema)).query; const { id, originalMp4, codec } = (await validate(req, schema)).query;
@ -68,7 +66,7 @@ router.get(path, async (req, res, next) => {
} }
const decryptionKey = const decryptionKey =
await getKeyFromCache(id, codecType.codecType) || await getKeyFromCache(id, codecType.codecType) ??
await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
await addKeyToCache(id, codecType.codecType, decryptionKey); await addKeyToCache(id, codecType.codecType, decryptionKey);

View file

@ -5,7 +5,7 @@ import formatDuration from "format-duration";
import { back, front } from "./endpoints/index.js"; import { back, front } from "./endpoints/index.js";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { AxiosError } from "axios"; import { errors as undiciErrors } from "undici";
import { env } from "../config.js"; import { env } from "../config.js";
import { createOpenApiDocument } from "./openApi.js"; import { createOpenApiDocument } from "./openApi.js";
@ -54,10 +54,11 @@ app.use((req, _res, next) => {
// ex. if the apple music api returns a 403, we want to return a 403 // ex. if the apple music api returns a 403, we want to return a 403
// this is so damn useful, i'm so glad i thought of this // this is so damn useful, i'm so glad i thought of this
app.use((err: AxiosError, _req: Request, _res: Response, next: NextFunction) => { // TODO: make this only happen on AM api? doesn't make too much sense otherwise
if (err instanceof AxiosError && err.response) { app.use((err: undiciErrors.ResponseError, _req: Request, _res: Response, next: NextFunction) => {
const status = err.response.status; if (err instanceof undiciErrors.ResponseError) {
const message = `upstream api error: ${err.response.status}`; const status = err.statusCode;
const message = `fetch error/upstream error: ${err.statusCode}. file an issue if unexpected/reoccuring`;
next(new HttpException(status, message)); next(new HttpException(status, message));
} else { } else {