diff --git a/package-lock.json b/package-lock.json index 5f6c4cf..1a43d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@libsql/client": "^0.15.12", "archiver": "^7.0.1", - "axios": "^1.11.0", "callsites": "^4.2.0", "chalk": "^5.4.1", "data-uri-to-buffer": "^6.0.2", @@ -28,6 +27,7 @@ "swagger-ui-express": "^5.0.1", "timeago.js": "^4.0.2", "toml": "^3.0.0", + "undici": "^7.16.0", "zod": "^4.0.14", "zod-openapi": "^5.3.0", "zod-validation-error": "^4.0.1" @@ -2326,23 +2326,6 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "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": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", @@ -2558,18 +2541,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -2782,15 +2753,6 @@ "dev": true, "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3059,21 +3021,6 @@ "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": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -3664,26 +3611,6 @@ "dev": true, "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3700,22 +3627,6 @@ "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": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/format-duration/-/format-duration-3.0.2.tgz", @@ -3992,21 +3903,6 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4473,27 +4369,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": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -4911,12 +4786,6 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pssh-tools/-/pssh-tools-1.2.0.tgz", @@ -5899,6 +5768,15 @@ "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": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", diff --git a/package.json b/package.json index 0f5bd54..996108e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "dependencies": { "@libsql/client": "^0.15.12", "archiver": "^7.0.1", - "axios": "^1.11.0", "callsites": "^4.2.0", "chalk": "^5.4.1", "data-uri-to-buffer": "^6.0.2", @@ -34,6 +33,7 @@ "swagger-ui-express": "^5.0.1", "timeago.js": "^4.0.2", "toml": "^3.0.0", + "undici": "^7.16.0", "zod": "^4.0.14", "zod-openapi": "^5.3.0", "zod-validation-error": "^4.0.1" diff --git a/src/appleMusicApi/index.ts b/src/appleMusicApi/index.ts index 1e80e9a..1f09eaf 100644 --- a/src/appleMusicApi/index.ts +++ b/src/appleMusicApi/index.ts @@ -1,4 +1,3 @@ -import axios, { type AxiosInstance } from "axios"; import { ampApiUrl, appleMusicHomepageUrl, licenseApiUrl, webplaybackApiUrl } from "../constants/urls.js"; import type { GetAlbumResponse, GetPlaylistResponse, GetSongResponse, SearchResponse } from "./types/responses.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 { HttpException } from "../web/index.js"; import type { RelationshipTypes } from "./types/relationships.js"; +import { fetch, request } from "undici"; export default class AppleMusicApi { private storefront: string; - private http: AxiosInstance; + private headers: Headers; + private params: URLSearchParams; public constructor( storefront: string, @@ -17,21 +18,58 @@ export default class AppleMusicApi { mediaUserToken: string ) { this.storefront = storefront; - this.http = axios.create({ - baseURL: ampApiUrl - }); - this.http.defaults.headers.common["Origin"] = appleMusicHomepageUrl; - this.http.defaults.headers.common["Media-User-Token"] = mediaUserToken; + this.headers = new Headers(); + 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 - // https://stackoverflow.com/a/54636780 - this.http.defaults.params = {}; - this.http.defaults.params["l"] = language; + this.params = new URLSearchParams(); + this.params.set("l", language); } public async login(): Promise { - 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 = {}): Promise { + 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 = {}): Promise { + 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< @@ -42,12 +80,10 @@ export default class AppleMusicApi { extend: T = [] as unknown[] as T, relationships: U = ["tracks"] as U ): Promise> { - return (await this.http.get>(`/v1/catalog/${this.storefront}/albums/${id}`, { - params: { - extend: extend.join(","), - include: relationships.join(",") - } - })).data; + return await this.get>(`${ampApiUrl}/v1/catalog/${this.storefront}/albums/${id}`, { + extend: extend.join(","), + include: relationships.join(",") + }); } // 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, relationships: U = ["tracks"] as U ): Promise> { - return (await this.http.get>(`/v1/catalog/${this.storefront}/playlists/${id}`, { - params: { - extend: extend.join(","), - include: relationships.join(",") - } - })).data; + return await this.get>(`${ampApiUrl}/v1/catalog/${this.storefront}/playlists/${id}`, { + extend: extend.join(","), + include: relationships.join(",") + }); } async getSong< @@ -80,12 +114,10 @@ export default class AppleMusicApi { extend: T = ["extendedAssetUrls"] as T, relationships: U = ["albums"] as U ): Promise> { - return (await this.http.get>(`/v1/catalog/${this.storefront}/songs/${id}`, { - params: { - extend: extend.join(","), - include: relationships.join(",") - } - })).data; + return await this.get>(`${ampApiUrl}/v1/catalog/${this.storefront}/songs/${id}`, { + extend: extend.join(","), + include: relationships.join(",") + }); } // TODO: add support for other types / abstract it for other types @@ -100,19 +132,17 @@ export default class AppleMusicApi { extend: T = [] as unknown[] as T, relationships: U = ["tracks"] as U ): Promise> { - return (await this.http.get(`/v1/catalog/${this.storefront}/search`, { - params: { - ...this.addScopingParameters("albums", relationships, extend), - ...{ - term: term, - types: ["albums", "songs"].join(","), // adding "songs" makes search results have albums when searching song name - limit: limit, - offset: offset, - extend: extend.join(","), - include: relationships.join(",") - } + return await this.get(`${ampApiUrl}/v1/catalog/${this.storefront}/search`, { + ...this.addScopingParameters("albums", relationships, extend), + ...{ + term: term, + types: ["albums", "songs"].join(","), // adding "songs" makes search results have albums when searching song name + limit: limit, + offset: offset, + extend: extend.join(","), + include: relationships.join(",") } - })).data; + }); } async getWebplayback( @@ -122,18 +152,18 @@ export default class AppleMusicApi { // 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/wjxwzk.png - const res = await this.http.post(webplaybackApiUrl, { + const res = await this.post(webplaybackApiUrl, { salableAdamId: trackId, language: config.downloader.api.language }); - if (res.data?.failureType === "3077") { + if (res?.failureType === "3077") { throw new HttpException(404, "track not found"); - } else if (res.data?.failureType !== undefined) { - throw new HttpException(500, `upstream webplayback api error: ${res.data.failureType}`); + } else if (res?.failureType !== undefined) { + throw new HttpException(500, `upstream webplayback api error: ${res.failureType}`); } - return res.data; + return res; } async getWidevineLicense( @@ -141,19 +171,14 @@ export default class AppleMusicApi { trackUri: string, challenge: string ): Promise { - return (await this.http.post(licenseApiUrl, { + return (await this.post(licenseApiUrl, { challenge: challenge, "key-system": "com.widevine.alpha", uri: trackUri, adamId: trackId, isLibrary: false, "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 diff --git a/src/appleMusicApi/token.ts b/src/appleMusicApi/token.ts index 4733020..912d343 100644 --- a/src/appleMusicApi/token.ts +++ b/src/appleMusicApi/token.ts @@ -1,3 +1,4 @@ +import { request } from "undici"; import * as log from "../log.js"; // 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 // 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 { - const indexResponse = await fetch(baseUrl); - const indexBody = await indexResponse.text(); + const indexResponse = await request(baseUrl); + const indexBody = await indexResponse.body.text(); const jsRegex = /\/assets\/index-legacy-[^/]+\.js/; const jsPath = indexBody.match(jsRegex)?.[0]; @@ -15,8 +16,8 @@ export async function getToken(baseUrl: string): Promise { throw new Error("could not match for the index javascript file!"); } - const jsResponse = await fetch(baseUrl + jsPath); - const jsBody = await jsResponse.text(); + const jsResponse = await request(baseUrl + jsPath); + const jsBody = await jsResponse.body.text(); // the token is actually a base64-encoded JWT // `eyJh` === `{"a`, which is the beginning of a JWT (a is the start of alg) diff --git a/src/downloader/index.ts b/src/downloader/index.ts index 7fee4c3..258c47d 100644 --- a/src/downloader/index.ts +++ b/src/downloader/index.ts @@ -9,6 +9,7 @@ import { createDecipheriv } from "node:crypto"; import type { AlbumAttributes } from "../appleMusicApi/types/attributes.js"; import { pipeline } from "node:stream/promises"; import { createWriteStream } from "node:fs"; +import { request } from "undici"; export async function downloadSongFile(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType, songResponse: GetSongResponse<[], ["albums"]>): Promise { 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: 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 { - const response = await fetch(segmentUrl, { headers: { "range": `bytes=${offset}-${offset + fetchLength - 1}` }}); - const file = new Uint8Array(await response.arrayBuffer()); + const response = await request(segmentUrl, { headers: { "range": `bytes=${offset}-${offset + fetchLength - 1}` }}); + const file = new Uint8Array(await response.body.bytes()); // this translates to "moof" 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); 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}`); } - if (!response.body) { throw new Error("no response body for artwork!"); } - - await pipeline(response.body as ReadableStream, createWriteStream(imagePath)); + await pipeline(response.body, createWriteStream(imagePath)); await addFileToCache(imageFileName); } diff --git a/src/downloader/streamInfo.ts b/src/downloader/streamInfo.ts index ec4362e..cec50a2 100644 --- a/src/downloader/streamInfo.ts +++ b/src/downloader/streamInfo.ts @@ -1,11 +1,11 @@ import * as log from "../log.js"; import type { SongAttributes } from "../appleMusicApi/types/attributes.js"; import hls, { Item } from "parse-hls"; -import axios from "axios"; import { widevine, playready, fairplay } from "../constants/keyFormats.js"; import { songCodecRegex } from "../constants/codecs.js"; import type { WebplaybackResponse } from "appleMusicApi/index.js"; import type { RegularCodecType, WebplaybackCodecType } from "./codecType.js"; +import { request } from "undici"; // why is this private // 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"); const m3u8Url = trackMetadata.extendedAssetUrls.enhancedHls; - const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); - const m3u8Parsed = hls.default.parse(m3u8.data); + const m3u8 = await request(m3u8Url); + const m3u8Parsed = hls.default.parse(await m3u8.body.text()); const drmInfos = getDrmInfos(m3u8Parsed); const assetInfos = getAssetInfos(m3u8Parsed); @@ -59,8 +59,8 @@ export default class StreamInfo { const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"]; const correctM3u8Url = m3u8Url.substring(0, m3u8Url.lastIndexOf("/")) + "/" + playlist.uri; - const correctM3u8 = await axios.get(correctM3u8Url, { responseType: "text" }); - const correctM3u8Parsed = hls.default.parse(correctM3u8.data); + const correctM3u8 = await request(correctM3u8Url); + const correctM3u8Parsed = hls.default.parse(await correctM3u8.body.text()); const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed); @@ -97,8 +97,8 @@ export default class StreamInfo { const trackId = song.songId; const m3u8Url = asset.URL; - const m3u8 = await axios.get(m3u8Url, { responseType: "text" }); - const m3u8Parsed = hls.default.parse(m3u8.data); + const m3u8 = await request(m3u8Url); + const m3u8Parsed = hls.default.parse(await m3u8.body.text()); const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed); diff --git a/src/index.ts b/src/index.ts index 403982f..fd7e220 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,14 +3,33 @@ import process from "node:process"; import * as log from "./log.js"; import { appleMusicApi } from "./appleMusicApi/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) => { - log.error("failed to login to apple music api"); +setGlobalDispatcher(new Agent().compose([ + 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); process.exit(1); -}).finally(() => { - log.info("logged in to apple music api"); -}); +} try { const listener = app.listen(config.server.port, () => { @@ -24,18 +43,7 @@ try { else { log.info(`hosting on http://localhost:${address.port}`); } }); } catch (err) { - log.error("failed to start server"); + log.error("failed to start server!"); log.error(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); -}); diff --git a/src/log.ts b/src/log.ts index 1bf3082..e9a0a09 100644 --- a/src/log.ts +++ b/src/log.ts @@ -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 warn(...message: unknown[]): void { log(Level.Warn, ...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); +}); diff --git a/src/web/endpoints/back/download.ts b/src/web/endpoints/back/download.ts index b33ba5c..c150cf0 100644 --- a/src/web/endpoints/back/download.ts +++ b/src/web/endpoints/back/download.ts @@ -53,10 +53,11 @@ router.get(path, async (req, res, next) => { } const decryptionKey = - await getKeyFromCache(id, codecType.codecType) || + await getKeyFromCache(id, codecType.codecType) ?? await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); await addKeyToCache(id, codecType.codecType, decryptionKey); + // TODO: stream to user 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 fileName = formatSongForFs(trackAttributes) + fileExt; diff --git a/src/web/endpoints/back/downloadAlbum.ts b/src/web/endpoints/back/downloadAlbum.ts index b649bab..43ec618 100644 --- a/src/web/endpoints/back/downloadAlbum.ts +++ b/src/web/endpoints/back/downloadAlbum.ts @@ -67,7 +67,7 @@ router.get(path, async (req, res, next) => { } const decryptionKey = - await getKeyFromCache(trackId, codecType.codecType) || + await getKeyFromCache(trackId, codecType.codecType) ?? await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); await addKeyToCache(trackId, codecType.codecType, decryptionKey); diff --git a/src/web/endpoints/back/downloadSegment.ts b/src/web/endpoints/back/downloadSegment.ts index 2a97e1f..a096bb1 100644 --- a/src/web/endpoints/back/downloadSegment.ts +++ b/src/web/endpoints/back/downloadSegment.ts @@ -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) => { try { const { id, originalMp4, codec } = (await validate(req, schema)).query; @@ -68,7 +66,7 @@ router.get(path, async (req, res, next) => { } const decryptionKey = - await getKeyFromCache(id, codecType.codecType) || + await getKeyFromCache(id, codecType.codecType) ?? await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); await addKeyToCache(id, codecType.codecType, decryptionKey); diff --git a/src/web/index.ts b/src/web/index.ts index 2d0b7f5..3f244bb 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -5,7 +5,7 @@ import formatDuration from "format-duration"; import { back, front } from "./endpoints/index.js"; import { ZodError } from "zod"; import { fromZodError } from "zod-validation-error"; -import { AxiosError } from "axios"; +import { errors as undiciErrors } from "undici"; import { env } from "../config.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 // this is so damn useful, i'm so glad i thought of this -app.use((err: AxiosError, _req: Request, _res: Response, next: NextFunction) => { - if (err instanceof AxiosError && err.response) { - const status = err.response.status; - const message = `upstream api error: ${err.response.status}`; +// TODO: make this only happen on AM api? doesn't make too much sense otherwise +app.use((err: undiciErrors.ResponseError, _req: Request, _res: Response, next: NextFunction) => { + if (err instanceof undiciErrors.ResponseError) { + const status = err.statusCode; + const message = `fetch error/upstream error: ${err.statusCode}. file an issue if unexpected/reoccuring`; next(new HttpException(status, message)); } else {