diff --git a/README.md b/README.md index 99a1ffe..5b44355 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,36 @@ a self-hostable web-ui apple music downloader widevine decryptor with questionab `WIDEVINE_CLIENT_ID` is uhm owie. this thing kind of Sucks to obtain and i would totally recommend finding a not-so-legal spot you can obtain this from (in fact, i found one on github LOL), rather than extracting it yourself. if you want to do through the pain like i did, check [this guide](forum.videohelp.com/threads/408031-Dumping-Your-own-L3-CDM-with-Android-Studio) out!! once you have your `client_id.bin` file, convert it to base64 and slap it in the env var (`cat client_id.bin | base64 -w 0`) -`WIDEVINE_PRIVATE_KEY` is essentially the same process of obtainment, you'll get it from the same guide!! i'm not sure how to easily find one of these on the web, but i'm sure you end users (user count: 0 (TRVTHNVKE)) can pull through. this is also in base64 (`cat private_key.pem | base64 -w 0`) +`WIDEVINE_PRIVATE_KEY` is essentially the same process of obtainment, you'll get it from the same guide!! i'm not sure how to easily find one of these on the web, but i'm sure you end users (user count: 0 (robert downey jr. true image) can pull through. this is also in base64 (`cat private_key.pem | base64 -w 0`) ### config most of the config is talked on in [`config.example.toml`](./config.example.toml), just copy it over to `config.toml` and go wild! i tried to make the error reporting for invalid configurations pretty good and digestable +### running + +just as easy as running `npm run build` and running the `dist/index.js` file with your javascript engine of choice + +alternatively, use the nix flake, which is currently broken due to an issue outside of my control (i can fix this by publishing a fork of `node-widevine` to the npm registry, but oh well, i'll do that eventually) + ## limitations / the formats -currently you can only get basic widevine ones, everything related to playready and fairplay is not supported, sorry!! someday i will get this working, at least for playready. it's just that no one has written a library yet +currently you can only get basic widevine ones, everything related to playready and fairplay encryption methods are not supported, sorry!! someday i will get this working, at least for playready. it's just that no one has written a library yet but has for python (yuck!!) guaranteed formats to work include: - aac-legacy - aac-he-legacy + +## screenshots + +
+screenshots + +![home screen](./docs/screen-home.png) + +![search screen](./docs/screen-search.png) + +![download screen](./docs/screen-dl.png) + +
diff --git a/config.example.toml b/config.example.toml index b959732..42bf08b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -3,6 +3,14 @@ # or a unix socket path (e.g. /tmp/sock) port = 2000 +[server.frontend] +# the amount of search results to show +# max 25, min 5 +search_count = 5 +# displayed codecs, recommended to use default +# see src/downloader/index.ts for a list of codecs +displayed_codecs = ["aac_legacy", "aac_he_legacy"] + [downloader] # path to the ffmpeg binary # will get from PATH if simply "ffmpeg" diff --git a/docs/screen-dl.png b/docs/screen-dl.png new file mode 100644 index 0000000..0742ccb Binary files /dev/null and b/docs/screen-dl.png differ diff --git a/docs/screen-home.png b/docs/screen-home.png new file mode 100644 index 0000000..ffd596a Binary files /dev/null and b/docs/screen-home.png differ diff --git a/docs/screen-search.png b/docs/screen-search.png new file mode 100644 index 0000000..6d49cbe Binary files /dev/null and b/docs/screen-search.png differ diff --git a/docs/true.png b/docs/true.png new file mode 100644 index 0000000..df099ba Binary files /dev/null and b/docs/true.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs index 8ad9515..c1a8ed5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,7 @@ export default [ // not very typescript-coded (typescript shouldnt require runtime debugging, thats the point of it) // TODO: find a rule to make seperators on interfaces consistent + // it... let's just say... it pmo "@typescript-eslint/explicit-function-return-type": "error", "@typescript-eslint/no-unused-vars": [ diff --git a/flake.nix b/flake.nix index 0f12677..88f19fa 100644 --- a/flake.nix +++ b/flake.nix @@ -22,8 +22,8 @@ # uncomment this and let the build fail, then get the current hash # very scuffed but endorsed! - #npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; - npmDepsHash = "sha256-XCGUKgLZxW7MonHswkp7mbvgeUlRCgBE3WnRjElf44Q="; + # npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + npmDepsHash = "sha256-f+RacjhkJP3RlK+yKJ8Xm0Rar4NyIxJjNQYDrpqhnD4="; installPhase = '' mkdir -p $out diff --git a/package-lock.json b/package-lock.json index d43fb16..6559ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "express-handlebars": "^8.0.1", + "format-duration": "^3.0.2", "git-rev-sync": "^3.0.2", "node-widevine": "https://github.com/wangziyingwen/node-widevine", "parse-hls": "^1.0.7", @@ -2073,6 +2074,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/format-duration": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/format-duration/-/format-duration-3.0.2.tgz", + "integrity": "sha512-pKzJDSRgK2lqAiPW3uizDaIJaJnataZclsahz25UMwfdryBGDa+1HlbXGjzpMvX/2kMh4O0sNevFXKaEfCjHsA==", + "license": "ISC" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", diff --git a/package.json b/package.json index a32a75b..fae0824 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "express-handlebars": "^8.0.1", + "format-duration": "^3.0.2", "git-rev-sync": "^3.0.2", "node-widevine": "https://github.com/wangziyingwen/node-widevine", "parse-hls": "^1.0.7", diff --git a/public/styles/main.css b/public/styles/main.css index 0ae6eb5..0b10c4f 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -10,6 +10,7 @@ --foreground-muted: #a6adc8; --background: #1e1e2e; --background-surface: #313244; + --background-surface-muted: #45475a; --shadow: #11111b; } @@ -30,9 +31,11 @@ main { flex-direction: column; align-items: center; justify-content: center; + margin-left: auto; + margin-right: auto; gap: 1em; padding: 1em; - width: 100%; + width: 60%; height: 100%; } @@ -41,39 +44,49 @@ h1, h2, h3, h4, h5, h6, p { margin-bottom: .8em; } -/* this isn't great in terms of accessibility */ -/* makes sense for most the stuff we got tho */ +p { + text-align: center; +} + a { color: var(--foreground); - text-decoration: none; + text-decoration: underline dotted; } a:hover { color: var(--foreground-muted); text-decoration: underline; } -.light { - color: var(--foreground-muted); +hr { + width: 100%; + height: 1px; + background-color: var(--background-surface-muted); + border: none; + margin: 0.25em 0; } input::placeholder { color: var(--foreground-muted); } -input[type="search"] { +input[type="search"], input[type="submit"], select { color: var(--foreground); background-color: var(--background-surface); border: 0; border-radius: 0.5em; padding: 0.5em 1em; - box-shadow: 0 0 2em var(--shadow); + box-shadow: 0 0 1em var(--shadow); +} + +input[type="submit"] { + cursor: pointer; } header { background-color: var(--background-surface); padding-left: 1em; padding-right: 1em; - box-shadow: 0 0 2em var(--shadow); + box-shadow: 0 0 1em var(--shadow); } header h1 { font-size: 1.2em; @@ -82,35 +95,125 @@ header h1 { footer { background-color: var(--background-surface); padding: 1em; - box-shadow: 0 0 2em var(--shadow); + box-shadow: 0 0 1em var(--shadow); +} + +.light { + color: var(--foreground-muted); +} + +.results { + list-style: none; + display: flex; + flex-direction: column; + align-items: center; + padding: 0; + gap: 1em; + width: 100%; } .result { display: flex; flex-direction: column; - width: 60%; gap: 1em; padding: 1em; background-color: var(--background-surface); - box-shadow: 0 0 2em var(--shadow); + box-shadow: 0 0 1em var(--shadow); border-radius: 0.5em; + width: 100%; } .result-info { display: flex; flex-direction: row; + align-items: center; gap: 1em; } .result-info img { - width: 5.5em; - height: 5.5em; - border-radius: 0.5em; + width: 4em; + height: 4em; + box-shadow: 0 0 1em var(--shadow); + transition: transform 0.2s ease-in-out; } -.result-text { +.result-info img:hover { + transform: scale(2); +} +.result-info-text { display: flex; flex-direction: column; } -.result-text h2 { +.result-info-text h2 { font-size: 1em; margin-top: 0; margin-bottom: 0; } +.result-tracklist { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; +} + +.track:nth-child(odd) { background-color: var(--background-surface-muted); } +.track:nth-child(even) { background-color: var(--background-surface); } +.track { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5em; + padding: 0.5em; + padding-right: 1em; +} +.track-number { + font-size: 0.8em; + text-align: center; + width: 2em; +} +.track-info h3 { + font-size: 1em; + margin-top: 0; + margin-bottom: 0; +} +.track-right { + display: flex; + flex-direction: row; + align-items: baseline; /* only thing that looks correct */ + margin-left: auto; + gap: 0.5em; +} +.track-time { + font-size: 0.8em; +} + +.paginator { + font-size: 1.2em; +} + +.download-form { + display: flex; + flex-direction: column; + gap: 1em; + padding: 1em; +} + +.error { + font-size: min(15em, 30vw); + font-weight: bold; + color: transparent; + --gradient: repeating-linear-gradient(45deg, var(--foreground), var(--foreground) 10px, var(--foreground-muted) 10px, var(--foreground-muted) 20px); + background: var(--gradient); + background-clip: text; + margin-top: 4rem; + margin-bottom: 1rem; +} + +.wrap { + word-wrap: anywhere; +} + +/* mobile */ +@media screen and (max-width: 600px) { + main { + width: 100%; + } +} diff --git a/src/api/appleMusicApi.ts b/src/api/appleMusicApi.ts deleted file mode 100644 index 396780d..0000000 --- a/src/api/appleMusicApi.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 - }); - - this.http.defaults.headers.common["Origin"] = appleMusicHomepageUrl; - this.http.defaults.headers.common["Media-User-Token"] = mediaUserToken; - // yeah dude. awesome - // https://stackoverflow.com/a/54636780 - this.http.defaults.params = {}; - this.http.defaults.params["l"] = language; - } - - public async login(): Promise { - this.http.defaults.headers.common["Authorization"] = `Bearer ${await getToken(appleMusicHomepageUrl)}`; - } - - async getSong< - T extends SongAttributesExtensionTypes = ["extendedAssetUrls"] - > ( - id: string, - extend: T = ["extendedAssetUrls"] as T - ): Promise> { - return (await this.http.get>(`/v1/catalog/${this.storefront}/songs/${id}`, { - params: { - extend: extend.join(",") - } - })).data; - } - - async getWebplayback( - trackId: string - ): Promise { - return (await this.http.post(webplaybackApiUrl, { - salableAdamId: trackId, - language: config.downloader.api.language - })).data; - } - - async getWidevineLicense( - trackId: string, - trackUri: string, - challenge: string - ): Promise { - return (await this.http.post(licenseApiUrl, { - challenge: challenge, - "key-system": "com.widevine.alpha", - uri: trackUri, - adamId: trackId, - isLibrary: false, - "user-initiated": true - }, { headers: { - // do these do anything. - "x-apple-music-user-token": this.http.defaults.headers.common["Media-User-Token"], - "x-apple-renewal": true - }})).data; - } -} - -// these are super special types -// i'm not putting this in the ./types folder. -export type WebplaybackResponse = { songList: { assets: { flavor: string, URL: string }[], songId: string }[] }; -export type WidevineLicenseResponse = { license: string | undefined }; diff --git a/src/api/index.ts b/src/api/index.ts deleted file mode 100644 index 7cebfab..0000000 --- a/src/api/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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); diff --git a/src/api/itunesApi.ts b/src/api/itunesApi.ts deleted file mode 100644 index 496602c..0000000 --- a/src/api/itunesApi.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 - } - }); - } -} diff --git a/src/api/types/appleMusic/extensions.ts b/src/api/types/appleMusic/extensions.ts deleted file mode 100644 index 38f25ef..0000000 --- a/src/api/types/appleMusic/extensions.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 - } -} diff --git a/src/api/types/appleMusic/responses.ts b/src/api/types/appleMusic/responses.ts deleted file mode 100644 index b07e377..0000000 --- a/src/api/types/appleMusic/responses.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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 - // TODO: add relationships - // https://developer.apple.com/documentation/applemusicapi/songs/relationships-data.dictionary - }[] -} diff --git a/src/appleMusicApi/index.ts b/src/appleMusicApi/index.ts new file mode 100644 index 0000000..04da88a --- /dev/null +++ b/src/appleMusicApi/index.ts @@ -0,0 +1,163 @@ +import axios, { type AxiosInstance } from "axios"; +import { ampApiUrl, appleMusicHomepageUrl, licenseApiUrl, webplaybackApiUrl } from "../constants/urls.js"; +import type { GetSongResponse, SearchResponse } from "./types/responses.js"; +import type { AlbumAttributesExtensionTypes, AnyAttributesExtensionTypes, SongAttributesExtensionTypes } from "./types/extensions.js"; +import { getToken } from "./token.js"; +import { config, env } from "../config.js"; +import { HttpException } from "../web/index.js"; +import type { RelationshipTypes } from "./types/relationships.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 + }); + + this.http.defaults.headers.common["Origin"] = appleMusicHomepageUrl; + this.http.defaults.headers.common["Media-User-Token"] = mediaUserToken; + + // yeah dude. awesome + // https://stackoverflow.com/a/54636780 + this.http.defaults.params = {}; + this.http.defaults.params["l"] = language; + } + + public async login(): Promise { + this.http.defaults.headers.common["Authorization"] = `Bearer ${await getToken(appleMusicHomepageUrl)}`; + } + + async getAlbum< + T extends AlbumAttributesExtensionTypes = [], + U extends RelationshipTypes = ["tracks"] + > ( + id: string, + 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; + } + + async getSong< + // TODO: possibly make this any, and use the addScopingParameters function? + // would be a bit cleaner, almost everywhere, use above in `getAlbum` perchancibly + T extends SongAttributesExtensionTypes = ["extendedAssetUrls"], + U extends RelationshipTypes = ["albums"] + > ( + id: string, + 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; + } + + // TODO: add support for other types / abstract it for other types + // i don't think we will use em, but completeness is peam + async search< + T extends AnyAttributesExtensionTypes = [], + U extends RelationshipTypes = ["tracks"] + > ( + term: string, + limit: number = 25, + offset: number = 0, + 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(",") + } + } + })).data; + } + + async getWebplayback( + trackId: string + ): Promise { + // this is one of those endpoints that returns a 200 + // no matter what happens, even if theres an error + // so we gotta do this stuuuupid hack + // TODO: find a better way to do this + const res = await this.http.post(webplaybackApiUrl, { + salableAdamId: trackId, + language: config.downloader.api.language + }); + + if (res.data?.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}`); + } + + return res.data; + } + + async getWidevineLicense( + trackId: string, + trackUri: string, + challenge: string + ): Promise { + return (await this.http.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 + // this is so i don't have to make those work in typescript + addScopingParameters( + names: string | string[], + relationships: string[], + extend: string[] + ): { [scope: string]: string } { + const params: { [scope: string]: string } = {}; + + for (const name of Array.isArray(names) ? names : [names]) { + for (const relationship of relationships) { params[`include[${name}]`] = relationship; } + for (const extendType of extend) { params[`extend[${names}]`] = extendType; } + } + + return params; + } +} + +export const appleMusicApi = new AppleMusicApi(env.ITUA, config.downloader.api.language, env.MEDIA_USER_TOKEN); + +// these are super special types +// i'm not putting this in the ./types folder. +// maybe ltr bleh +export type WebplaybackResponse = { songList: { assets: { flavor: string, URL: string }[], songId: string }[] }; +export type WidevineLicenseResponse = { license: string | undefined }; diff --git a/src/api/token.ts b/src/appleMusicApi/token.ts similarity index 95% rename from src/api/token.ts rename to src/appleMusicApi/token.ts index 45a0436..58de2c0 100644 --- a/src/api/token.ts +++ b/src/appleMusicApi/token.ts @@ -3,7 +3,7 @@ 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) +// 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(); @@ -12,7 +12,7 @@ export async function getToken(baseUrl: string): Promise { const jsPath = indexBody.match(jsRegex)?.[0]; if (!jsPath) { - 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); diff --git a/src/api/types/appleMusic/attributes.ts b/src/appleMusicApi/types/attributes.ts similarity index 52% rename from src/api/types/appleMusic/attributes.ts rename to src/appleMusicApi/types/attributes.ts index df32135..b9ef22b 100644 --- a/src/api/types/appleMusic/attributes.ts +++ b/src/appleMusicApi/types/attributes.ts @@ -1,10 +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"; +import type { + AlbumAttributesExtensionMap, AlbumAttributesExtensionTypes, + SongAttributesExtensionMap, SongAttributesExtensionTypes +} from "./extensions.js"; + +export type AlbumAttributes< + T extends AlbumAttributesExtensionTypes, +> = { + artistName: string + artwork: Artwork + contentRating?: string + copyright?: string + editorialNotes?: EditorialNotes + genreNames: string[] + isCompilation: boolean + isComplete: boolean + isMasteredForItunes: boolean + isSingle: boolean + name: string + playParams?: PlayParameters + recordLabel?: string + releaseDate?: string + trackCount: number + upc?: string + url: string +} + & Pick export type SongAttributes< T extends SongAttributesExtensionTypes, - // U extends SongAttributesRelationshipTypes > = { albumName: string artistName: string @@ -31,4 +55,3 @@ export type SongAttributes< workName?: string } & Pick - // & Pick diff --git a/src/appleMusicApi/types/extensions.ts b/src/appleMusicApi/types/extensions.ts new file mode 100644 index 0000000..6e2cb75 --- /dev/null +++ b/src/appleMusicApi/types/extensions.ts @@ -0,0 +1,23 @@ +export type AnyAttributesExtensionType = AlbumAttributesExtensionType | SongAttributesExtensionType; +export type AnyAttributesExtensionTypes = AnyAttributesExtensionType[]; + +export type AlbumAttributesExtensionType = keyof AlbumAttributesExtensionMap; +export type AlbumAttributesExtensionTypes = AlbumAttributesExtensionType[]; +export type AlbumAttributesExtensionMap = { + artistUrl: string + audioVariants?: string[] +} + +export type SongAttributesExtensionType = keyof SongAttributesExtensionMap; +export type SongAttributesExtensionTypes = SongAttributesExtensionType[]; +export type SongAttributesExtensionMap = { + artistUrl: string, + audioVariants?: string[], + extendedAssetUrls: { + plus: string, + lightweight: string + superLightweight: string + lightweightPlus: string + enhancedHls: string + } +} diff --git a/src/api/types/appleMusic/extras.ts b/src/appleMusicApi/types/extras.ts similarity index 70% rename from src/api/types/appleMusic/extras.ts rename to src/appleMusicApi/types/extras.ts index 616d646..7a6a37e 100644 --- a/src/api/types/appleMusic/extras.ts +++ b/src/appleMusicApi/types/extras.ts @@ -1,8 +1,3 @@ -// 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 diff --git a/src/appleMusicApi/types/relationships.ts b/src/appleMusicApi/types/relationships.ts new file mode 100644 index 0000000..84d23a6 --- /dev/null +++ b/src/appleMusicApi/types/relationships.ts @@ -0,0 +1,24 @@ +// you will shit yourself if you don't read this: +// required reading: https://developer.apple.com/documentation/applemusicapi/handling-resource-representation-and-relationships + +import type { AlbumAttributes, SongAttributes } from "./attributes.js"; +import type { AlbumAttributesExtensionTypes, AnyAttributesExtensionTypes, SongAttributesExtensionTypes } from "./extensions.js"; + +// TODO: have something like this for every resource +export type Relationship = { + href?: string; + next?: string; + data: { + // TODO: there is extra types here (id, type, etc) i just can't cba to add them lol + // probably not important ! ahahahah + // seems to be the same basic "resource" pattern i'm starting to notice (id(?), href, type, meta, etc) + attributes: T + }[] +} + +export type RelationshipType = keyof RelationshipTypeMap; +export type RelationshipTypes = RelationshipType[]; +export type RelationshipTypeMap = { + albums: AlbumAttributes>, + tracks: SongAttributes> // TODO: tracks can also be music videos, uh oh. +} diff --git a/src/appleMusicApi/types/responses.ts b/src/appleMusicApi/types/responses.ts new file mode 100644 index 0000000..7b96ce1 --- /dev/null +++ b/src/appleMusicApi/types/responses.ts @@ -0,0 +1,82 @@ +// this is a semi-reverse engineered type definition set for the apple music API +// I WILL NOT ADD "VIEWS" THEY ARE NOT REAL +// [scoping parameters](https://developer.apple.com/documentation/applemusicapi/handling-resource-representation-and-relationships) assume that we pass down all of the extensions through, this must be reflected in the usage of the api +// there is a small chance i will add more endpoints + +import type { AlbumAttributes, SongAttributes } from "./attributes.js"; +import type { AlbumAttributesExtensionTypes, AnyAttributesExtensionTypes, SongAttributesExtensionTypes } from "./extensions.js"; +import type { Relationship, RelationshipType, RelationshipTypeMap, RelationshipTypes } from "./relationships.js"; + +// https://developer.apple.com/documentation/applemusicapi/get-a-catalog-album +export interface GetAlbumResponse< + T extends AlbumAttributesExtensionTypes, + U extends RelationshipTypes +> { + // https://developer.apple.com/documentation/applemusicapi/albums + data: { + id: string, + type: "albums", + href: string, + // https://developer.apple.com/documentation/applemusicapi/albums/attributes-data.dictionary + attributes: AlbumAttributes, + // https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary + relationships: { + [K in U[number]]: Relationship< + K extends RelationshipType ? RelationshipTypeMap[K] : never + > + } + }[] +} + +// https://developer.apple.com/documentation/applemusicapi/get-a-catalog-song +export interface GetSongResponse< + T extends SongAttributesExtensionTypes, + U extends RelationshipTypes +> { + // 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 + // https://developer.apple.com/documentation/applemusicapi/songs/relationships-data.dictionary + relationships: { + [K in U[number]]: Relationship< + K extends RelationshipType ? RelationshipTypeMap[K] : never + > + } + }[] +} + +// TODO: support more than just albums +// TODO: figure out how to only get *some* attributes, rn i just hardcode albums +// TODO: dedupe when done above ^ +// requires changing impl of search function in appleMusicApi.ts, guh ! +// https://developer.apple.com/documentation/applemusicapi/searchresponse +export interface SearchResponse< + T extends AnyAttributesExtensionTypes, + U extends RelationshipTypes +> { + // https://developer.apple.com/documentation/applemusicapi/searchresponse/results-data.dictionary + results: { + // https://developer.apple.com/documentation/applemusicapi/searchresponse/results-data.dictionary/albumssearchresult + albums?: { + // https://developer.apple.com/documentation/applemusicapi/albums + data: { + id: string, + type: "albums", + href: string, + attributes: AlbumAttributes>, + // https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary + relationships: { + [K in U[number]]: Relationship< + K extends RelationshipType ? RelationshipTypeMap[K] : never + > + } + }[], + href?: string, + next?: string, + } + } +} diff --git a/src/cache.ts b/src/cache.ts index 283a8bb..e40d4d4 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -11,6 +11,7 @@ import * as log from "./log.js"; // TODO: swap to sqlite // TODO: make async fs calls // TODO: rework EVERYTHING +// TODO: refresh cache timer on download interface CacheEntry { fileName: string; @@ -93,6 +94,3 @@ export function addToCache(fileName: string): void { }); rewriteCache(); } - -// setTimeout(() => addToCache("jorry.tx"), 1000); - diff --git a/src/config.ts b/src/config.ts index 8f21d9f..e8c90b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,11 @@ dotenv.config(); const configSchema = z.object({ server: z.object({ - port: z.number().int().min(0).max(65535).or(z.string()) + port: z.number().int().min(0).max(65535).or(z.string()), + frontend: z.object({ + search_count: z.number().int().min(5).max(25), + displayed_codecs: z.array(z.string()) + }) }), downloader: z.object({ ffmpeg_path: z.string(), diff --git a/src/downloader/index.ts b/src/downloader/index.ts index 431a8d3..4f67888 100644 --- a/src/downloader/index.ts +++ b/src/downloader/index.ts @@ -3,11 +3,9 @@ import { spawn } from "node:child_process"; import path from "node:path"; import { addToCache, isCached } from "../cache.js"; -// TODO: refresh cache timer on download -// TODO: remux to m4a? export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodec | WebplaybackCodec): Promise { - let baseOutputName = streamUrl.split("/").at(-1)?.split("?").at(0)?.split(".").splice(0, 1).join(".")?.trim(); - if (!baseOutputName) { throw "could not get base output name from stream url"; } + let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1]; + if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); } baseOutputName += `_${songCodec}`; const encryptedName = baseOutputName + "_enc.mp4"; const encryptedPath = path.join(config.downloader.cache.directory, encryptedName); @@ -28,11 +26,14 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son "--paths", config.downloader.cache.directory, "--output", encryptedName, streamUrl - ]).on("error", (err) => { rej(err); }); - child.stderr.on("data", (chunk) => { rej(chunk); }); + ]); + child.on("error", (err) => { rej(err); }); + child.stderr.on("data", (data) => { rej(new Error(data.toString().trim())); }); child.on("exit", () => { res(); }); }); + addToCache(encryptedName); + await new Promise((res, rej) => { const child = spawn(config.downloader.ffmpeg_path, [ "-loglevel", "error", @@ -42,12 +43,12 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son "-c", "copy", "-movflags", "+faststart", decryptedPath - ]).on("error", (err) => { rej(err); }); - child.stderr.on("data", (chunk) => { rej(chunk); }); + ]); + child.on("error", (err) => { rej(err); }); + child.stderr.on("data", (data) => { rej(new Error(data.toString().trim())); }); child.on("exit", () => { res(); } ); }); - addToCache(encryptedName); addToCache(decryptedName); return decryptedPath; diff --git a/src/downloader/keygen.ts b/src/downloader/keygen.ts index 2cf006e..d9fb914 100644 --- a/src/downloader/keygen.ts +++ b/src/downloader/keygen.ts @@ -1,6 +1,6 @@ import { LicenseType, Session } from "node-widevine"; import { env } from "../config.js"; -import { appleMusicApi } from "../api/index.js"; +import { appleMusicApi } from "../appleMusicApi/index.js"; import { dataUriToBuffer } from "data-uri-to-buffer"; import psshTools from "pssh-tools"; import * as log from "../log.js"; @@ -19,7 +19,7 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str // for some reason, if gotten from a webplayback manifest, the pssh is in a completely different format // well, somewhat. it's just the raw data, we have to rebuild the pssh const rebuiltPssh = psshTools.widevine.encodePssh({ - contentId: "meow", // this actually isn't even needed, but this library is stubborn + contentId: "meow", // this actually isn't even needed, but this library is somewhat-stubborn dataOnly: false, keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")] }); @@ -38,11 +38,11 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str challenge.toString("base64") ); - if (typeof response?.license !== "string") { throw "license is gone/not a string! sign that auth failed (unsupported codec?)"; } + if (typeof response?.license !== "string") { throw new Error("license is gone/not a string! maybe auth failed (unsupported codec?)"); } const license = session.parseLicense(Buffer.from(response.license, "base64")); - if (license.length === 0) { throw "license(s) failed to be parsed. this could be an error showing invalid data! (e.x. pssh/challenge)"; } + if (license.length === 0) { throw new Error("license(s) can't parse. this may be an error showing invalid data! (ex. pssh/challenge)"); } const validKey = license.find((keyPair) => { return keyPair?.key?.length === 32; })?.key; - if (validKey === undefined) { throw "no valid key found in license"; } + if (validKey === undefined) { throw new Error("no valid key found in license!"); } return validKey; } diff --git a/src/downloader/streamInfo.ts b/src/downloader/streamInfo.ts index 91715c8..a6fea0e 100644 --- a/src/downloader/streamInfo.ts +++ b/src/downloader/streamInfo.ts @@ -1,30 +1,16 @@ import * as log from "../log.js"; -import type { SongAttributes } from "../api/types/appleMusic/attributes.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 "api/appleMusicApi.js"; +import type { WebplaybackResponse } from "appleMusicApi/index.js"; import { RegularCodec, WebplaybackCodec } from "./index.js"; // why is this private // i wish pain on the person who wrote this /j :smile: type M3u8 = ReturnType; -// 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 -// SOLVED. widevine keys are not always present in the m3u8 manifest that is default (you can see that in link above, thats why it exists) -// OH. it doesn't seem to give the keys you want anyway LOLLLLLLL???? -// i'm sure its used for *SOMETHING* so i'll keep it - export default class StreamInfo { public readonly trackId: string; public readonly streamUrl: string; @@ -60,20 +46,22 @@ export default class StreamInfo { const assetInfos = getAssetInfos(m3u8Parsed); const playlist = await getPlaylist(m3u8Parsed, codec); const variantId = playlist.properties[0].attributes.stableVariantId; - if (variantId === undefined) { throw "variant id does not exist!"; } - if (typeof variantId !== "string") { throw "variant id is not a string!"; } + if (variantId === undefined) { throw new Error("variant id does not exist!"); } + if (typeof variantId !== "string") { throw new Error("variant id is not a string!"); } const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"]; + const correctM3u8Url = m3u8Url.substring(0, m3u8Url.lastIndexOf("/")) + "/" + playlist.uri; + const widevinePssh = getWidevinePssh(drmInfos, drmIds); const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds); const fairplayKey = getFairplayKey(drmInfos, drmIds); const trackId = trackMetadata.playParams?.id; - if (trackId === undefined) { throw "track id is missing, this may indicate your song isn't accessable w/ your subscription!"; } + if (trackId === undefined) { throw new Error("track id gone, this may indicate your song isn't accessable w/ your subscription!"); } return new StreamInfo( trackId, - m3u8Url, // TODO: make this keep in mind the CODEC, yt-dlp will shit itself if not supplied i think + correctM3u8Url, widevinePssh, playreadyPssh, fairplayKey @@ -90,7 +78,7 @@ export default class StreamInfo { else if (codec === WebplaybackCodec.AacLegacy) { flavor = "28:ctrp256"; } const asset = song.assets.find((asset) => { return asset.flavor === flavor; }); - if (asset === undefined) { throw "webplayback info for requested flavor doesn't exist!"; } + if (asset === undefined) { throw new Error("webplayback info for requested flavor doesn't exist!"); } const trackId = song.songId; @@ -99,8 +87,8 @@ export default class StreamInfo { const m3u8Parsed = hls.default.parse(m3u8.data); const widevinePssh = m3u8Parsed.lines.find((line) => { return line.name === "key"; })?.attributes?.uri; - if (widevinePssh === undefined) { throw "widevine uri is missing!"; } - if (typeof widevinePssh !== "string") { throw "widevine uri is not a string!"; } + if (widevinePssh === undefined) { throw new Error("widevine uri is missing!"); } + if (typeof widevinePssh !== "string") { throw new Error("widevine uri is not a string!"); } // afaik this ONLY has widevine return new StreamInfo( @@ -122,14 +110,14 @@ function getDrmInfos(m3u8Data: M3u8): DrmInfos { line.content.includes("com.apple.hls.AudioSessionKeyInfo") ) { const value = line.content.match(/VALUE="([^"]+)"/); - if (!value) { throw "could not match for drm key value!"; } - if (!value[1]) { throw "drm key value is empty!"; } + if (!value) { throw new Error("could not match for drm key value!"); } + if (!value[1]) { throw new Error("drm key value is empty!"); } return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8")); } } - throw "m3u8 missing audio session key info!"; + throw new Error("m3u8 missing audio session key info!"); } type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; } @@ -143,19 +131,16 @@ function getAssetInfos(m3u8Data: M3u8): AssetInfos { line.content.includes("com.apple.hls.audioAssetMetadata") ) { const value = line.content.match(/VALUE="([^"]+)"/); - if (!value) { throw "could not match for value!"; } - if (!value[1]) { throw "value is empty!"; } + if (!value) { throw new Error("could not match for value!"); } + if (!value[1]) { throw new Error("value is empty!"); } return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8")); } } - throw "m3u8 missing audio asset metadata!"; + throw new Error("m3u8 missing audio asset metadata!"); } -// SUPER TODO: remove inquery for the codec, including its library, this is for testing -// add a config option for preferred codec ? -// or maybe in the streaminfo function async function getPlaylist(m3u8Data: M3u8, codec: RegularCodec): Promise { const masterPlaylists = m3u8Data.streamRenditions; const masterPlaylist = masterPlaylists.find((playlist) => { @@ -166,7 +151,7 @@ async function getPlaylist(m3u8Data: M3u8, codec: RegularCodec): Promise { return match !== null; }); - if (masterPlaylist === undefined) { throw "no master playlist for codec found!"; } + if (masterPlaylist === undefined) { throw new Error("no master playlist for codec found!"); } return masterPlaylist; } diff --git a/src/index.ts b/src/index.ts index b2c1544..403982f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { config } from "./config.js"; import process from "node:process"; import * as log from "./log.js"; -import { appleMusicApi } from "./api/index.js"; +import { appleMusicApi } from "./appleMusicApi/index.js"; import { app } from "./web/index.js"; await appleMusicApi.login().catch((err) => { @@ -39,8 +39,3 @@ process.on("unhandledRejection", (err) => { log.error(err); process.exit(1); }); - -// TODO: remove later -// this is for testing purposes -await import("./downloader/streamInfo.js"); -await import("./cache.js"); diff --git a/src/log.ts b/src/log.ts index 1f870e7..616c996 100644 --- a/src/log.ts +++ b/src/log.ts @@ -6,11 +6,10 @@ 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 { + Http, Debug, Info, Warn, @@ -18,12 +17,14 @@ enum Level { } const levelColors = { + [Level.Http]: chalk.gray, [Level.Debug]: chalk.blue, [Level.Info]: chalk.green, [Level.Warn]: chalk.yellow, [Level.Error]: chalk.red }; const levelNames = { + [Level.Http]: "HTTP", [Level.Debug]: "DEBUG", [Level.Info]: "INFO", [Level.Warn]: "WARN", @@ -78,6 +79,7 @@ function log(level: Level, ...message: unknown[]): void { process.stdout.write(`${prefix} ${formatted.split("\n").join("\n" + prefix)}\n`); } +export function http(...message: unknown[]): void { log(Level.Http, ...message); } export function debug(...message: unknown[]): void { log(Level.Debug, ...message); } export function info(...message: unknown[]): void { log(Level.Info, ...message); } export function warn(...message: unknown[]): void { log(Level.Warn, ...message); } diff --git a/src/web/endpoints/back/dlTrackMetadata.ts b/src/web/endpoints/back/dlTrackMetadata.ts deleted file mode 100644 index 9b40abc..0000000 --- a/src/web/endpoints/back/dlTrackMetadata.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getWidevineDecryptionKey } from "../../../downloader/keygen.js"; -import { downloadSong, RegularCodec } from "../../../downloader/index.js"; -import express from "express"; -import StreamInfo from "../../../downloader/streamInfo.js"; -import { appleMusicApi } from "../../../api/index.js"; - -const router = express.Router(); - -// TODO: support more encryption schemes -// TODO: some type of agnostic-ness for the encryption keys -router.get("/dlTrackMetadata", async (req, res, next) => { - try { - const { trackId, codec } = req.query; - if (typeof trackId !== "string") { res.status(400).send("trackId is required and must be a string!"); return; } - if (typeof codec !== "string") { res.status(400).send("codec is required and must be a string!"); return; } - - const c = Object.values(RegularCodec).find((c) => { return c === codec; }); - if (c === undefined) { res.status(400).send("codec is invalid!"); return; } - - const trackMetadata = await appleMusicApi.getSong(trackId); - const trackAttributes = trackMetadata.data[0].attributes; - const streamInfo = await StreamInfo.fromTrackMetadata(trackAttributes, c); - if (streamInfo.widevinePssh !== undefined) { - const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); - const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, c); - res.download(filePath); - } else { - res.status(400).send("no decryption key found!"); - } - } catch (err) { - next(err); - } -}); - -export default router; diff --git a/src/web/endpoints/back/dlWebplayback.ts b/src/web/endpoints/back/dlWebplayback.ts deleted file mode 100644 index c2cd751..0000000 --- a/src/web/endpoints/back/dlWebplayback.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getWidevineDecryptionKey } from "../../../downloader/keygen.js"; -import { downloadSong, WebplaybackCodec } from "../../../downloader/index.js"; -import express from "express"; -import StreamInfo from "../../../downloader/streamInfo.js"; -import { appleMusicApi } from "../../../api/index.js"; - -const router = express.Router(); - -router.get("/dlWebplayback", async (req, res, next) => { - try { - const { trackId, codec } = req.query; - if (typeof trackId !== "string") { res.status(400).send("trackId is required and must be a string!"); return; } - if (typeof codec !== "string") { res.status(400).send("codec is required and must be a string!"); return; } - - const c = Object.values(WebplaybackCodec).find((c) => { return c === codec; }); - if (c === undefined) { res.status(400).send("codec is invalid!"); return; } - - // TODO: check if this returns an error - const webplaybackResponse = await appleMusicApi.getWebplayback(trackId); - console.log(webplaybackResponse); - const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, c); - if (streamInfo.widevinePssh !== undefined) { - const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); - const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, c); - res.download(filePath); - } else { - res.status(400).send("no decryption key found!"); - } - } catch (err) { - next(err); - } -}); - -export default router; diff --git a/src/web/endpoints/back/download.ts b/src/web/endpoints/back/download.ts new file mode 100644 index 0000000..674009c --- /dev/null +++ b/src/web/endpoints/back/download.ts @@ -0,0 +1,58 @@ +import { getWidevineDecryptionKey } from "../../../downloader/keygen.js"; +import { downloadSong, RegularCodec, WebplaybackCodec } from "../../../downloader/index.js"; +import express from "express"; +import StreamInfo from "../../../downloader/streamInfo.js"; +import { appleMusicApi } from "../../../appleMusicApi/index.js"; +import { z } from "zod"; +import { validate } from "../../validate.js"; + +const router = express.Router(); + +const schema = z.object({ + query: z.object({ + id: z.string(), + codec: z.nativeEnum(RegularCodec).or(z.nativeEnum(WebplaybackCodec)) + }) +}); + +// TODO: support more encryption schemes +// TODO: some type of agnostic-ness for the encryption schemes on regular codec +// TODO: make it less ugly,, hahahiwehio +router.get("/download", async (req, res, next) => { + try { + const { id, codec } = (await validate(req, schema)).query; + + // TODO: write helper function for this + // or make it a class so we can use `instanceof` + const regularCodec = Object.values(RegularCodec).find((c) => { return c === codec; }); + const webplaybackCodec = Object.values(WebplaybackCodec).find((c) => { return c === codec; }); + if (regularCodec === undefined && webplaybackCodec === undefined) { res.status(400).send("codec is invalid!"); return; } + + if (regularCodec !== undefined) { + const trackMetadata = await appleMusicApi.getSong(id); + const trackAttributes = trackMetadata.data[0].attributes; + const streamInfo = await StreamInfo.fromTrackMetadata(trackAttributes, regularCodec); + if (streamInfo.widevinePssh !== undefined) { + const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); + const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, regularCodec); + res.download(filePath); + } else { + res.status(400).send("no decryption key found!"); + } + } else if (webplaybackCodec !== undefined) { + const webplaybackResponse = await appleMusicApi.getWebplayback(id); + const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec); + if (streamInfo.widevinePssh !== undefined) { + const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId); + const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, webplaybackCodec); + res.download(filePath); + } else { + res.status(400).send("no decryption key found!"); + } + } + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/web/endpoints/back/getAlbumMetadata.ts b/src/web/endpoints/back/getAlbumMetadata.ts new file mode 100644 index 0000000..c865dac --- /dev/null +++ b/src/web/endpoints/back/getAlbumMetadata.ts @@ -0,0 +1,28 @@ +import { appleMusicApi } from "../../../appleMusicApi/index.js"; +import express from "express"; +import { validate } from "../../validate.js"; +import { z } from "zod"; + +const router = express.Router(); + +const schema = z.object({ + query: z.object({ + id: z.string() + }) +}); + +// see comments in `getTrackMetadata.ts` +// awawawawawa +router.get("/getAlbumMetadata", async (req, res, next) => { + try { + const { id } = (await validate(req, schema)).query; + + const albumMetadata = await appleMusicApi.getAlbum(id); + + res.json(albumMetadata); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/web/endpoints/back/getTrackMetadata.ts b/src/web/endpoints/back/getTrackMetadata.ts index a16e436..aa66513 100644 --- a/src/web/endpoints/back/getTrackMetadata.ts +++ b/src/web/endpoints/back/getTrackMetadata.ts @@ -1,20 +1,26 @@ -import { appleMusicApi } from "../../../api/index.js"; +import { appleMusicApi } from "../../../appleMusicApi/index.js"; import express from "express"; +import { validate } from "../../validate.js"; +import { z } from "zod"; const router = express.Router(); +const schema = z.object({ + query: z.object({ + id: z.string() + }) +}); + // this endpoint isn't actually used for anything by us -// it's for people who want to implement apple music downloading into their own apps +// it's for people who want to implement apple music downloading into their own apps (ex. discord music bot) // it makes it a bit easier to get the metadata for a track knowing the trackId router.get("/getTrackMetadata", async (req, res, next) => { try { - const { trackId } = req.query; - if (typeof trackId !== "string") { res.status(400).send("trackId is required and must be a string!"); return; } + const { id } = (await validate(req, schema)).query; - const trackMetadata = await appleMusicApi.getSong(trackId); - const trackAttributes = trackMetadata.data[0].attributes; + const trackMetadata = await appleMusicApi.getSong(id); - res.json(trackAttributes); + res.json(trackMetadata); } catch (err) { next(err); } diff --git a/src/web/endpoints/front/download.ts b/src/web/endpoints/front/download.ts new file mode 100644 index 0000000..e240a07 --- /dev/null +++ b/src/web/endpoints/front/download.ts @@ -0,0 +1,28 @@ +import express from "express"; +import { validate } from "../../validate.js"; +import { z } from "zod"; +import { config } from "../../../config.js"; + +const router = express.Router(); + +const schema = z.object({ + query: z.object({ + id: z.string() + }) +}); + +router.get("/download", async (req, res, next) => { + try { + const { id } = (await validate(req, schema)).query; + + res.render("download", { + title: "download", + codecs: config.server.frontend.displayed_codecs, + id: id + }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/web/endpoints/front/search.ts b/src/web/endpoints/front/search.ts index 3a7fcb3..9d2da03 100644 --- a/src/web/endpoints/front/search.ts +++ b/src/web/endpoints/front/search.ts @@ -1,45 +1,66 @@ import express from "express"; -import gitRepoInfo from "git-rev-sync"; - -// TODO: move this into a helper or whatever? -// i don't wanna do this for every single page lol -const hash = gitRepoInfo.short("./"); -const dirty = gitRepoInfo.isDirty(); +import { validate } from "../../validate.js"; +import { z } from "zod"; +import { appleMusicApi } from "../../../appleMusicApi/index.js"; +import { config } from "../../../config.js"; +import queryString from "node:querystring"; const router = express.Router(); -// TODO: implement this -// TODO: show tracks -// TODO: add a download button -router.get("/", (req, res) => { - res.render("search", { - title: "search", - hash: hash, - dirty: dirty, - query: req.query.q, - results: [ - { - name: "Revengeseekerz", - artists: ["Jane Remover"], - cover: "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/18/cf/f6/18cff6df-c7b6-0ca1-8067-83743f6c1f8a/193436418720_coverGriffinMcMahon.jpg/592x592bb.webp" - } - // { - // name: "Carousel (An Examination of the Shadow, Creekflow, And its Life as an Afterthought) ", - // artists: ["Vylet Pony"], - // tracks: [ - // { - // artists: ["Vylet Pony"], - // name: "Carousel" - // }, - // { - // artists: ["Vylet Pony", "Namii"], - // name: "The Shadow" - // } - // ], - // cover: "https://is1-ssl.mzstatic.com/image/thumb/Music116/v4/7c/f0/94/7cf09429-4942-a9cb-1287-b8bbc53a4d61/artwork.jpg/592x592bb.webp" - // } - ] - }); +const schema = z.object({ + query: z.object({ + q: z.optional(z.string()), + page: z.optional(z.coerce.number().int().min(0)) + }) +}); + +router.get("/", async (req, res, next) => { + try { + const { q, page } = (await validate(req, schema)).query; + + const offset = page ? (page - 1) * config.server.frontend.search_count : 0; + const results = (q && await appleMusicApi.search(q, config.server.frontend.search_count, offset)) || undefined; + const albums = results?.results?.albums; + + res.render("search", { + title: "search", + query: q, + page: page ?? 1, + back: req.path + "?" + queryString.stringify({ q: q, page: (page ?? 1) - 1 }), + next: (albums?.next !== undefined && req.path + "?" + queryString.stringify({ q: q, page: (page ?? 1) + 1 })) || undefined, + results: albums?.data.map((result) => { + const { artistName, artwork, name } = result.attributes; + + // use 220x220 cover, it's what the real apple music uses for larger sizes on the tag for search results + // the reason we should use this is that apple won't have to resize the image for us (cached), making this slightly snappier + // may be lying, but logically it makes sense + // in fact: `x-cache-hits` is greater than 0, sometimes :) + const cover = artwork.url.replace("{w}", "220").replace("{h}", "220"); + const tracks = result.relationships.tracks.data; + + return { + name: name, + artists: [artistName], + cover: cover, + tracks: tracks.map((track) => { + const { artistName, name, durationInMillis, discNumber, trackNumber } = track.attributes; + + return { + discNumber: discNumber, + trackNumber: trackNumber, + name: name, + artists: [artistName], + duration: durationInMillis, + cover: cover, + id: track.attributes.playParams?.id + }; + }) + }; + }) + }); + } catch (err) { + next(err); + } }); export default router; diff --git a/src/web/endpoints/index.ts b/src/web/endpoints/index.ts new file mode 100644 index 0000000..5e73ac7 --- /dev/null +++ b/src/web/endpoints/index.ts @@ -0,0 +1,15 @@ +import frontDownload from "./front/download.js"; +import search from "./front/search.js"; +export const front = [ + frontDownload, + search +]; + +import backDownload from "./back/download.js"; +import getAlbumMetadata from "./back/getAlbumMetadata.js"; +import getTrackMetadata from "./back/getTrackMetadata.js"; +export const back = [ + backDownload, + getAlbumMetadata, + getTrackMetadata +]; diff --git a/src/web/index.ts b/src/web/index.ts index 657b069..f1123dc 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -1,11 +1,15 @@ import * as log from "../log.js"; import express, { type NextFunction, type Request, type Response } from "express"; -import { engine } from "express-handlebars"; +import { create } from "express-handlebars"; +import gitRevSync from "git-rev-sync"; +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 dlTrackMetadata from "./endpoints/back/dlTrackMetadata.js"; -import dlWebplayback from "./endpoints/back/dlWebplayback.js"; -import getTrackMetadata from "./endpoints/back/getTrackMetadata.js"; -import search from "./endpoints/front/search.js"; +const rev = gitRevSync.short("./"); +const dirty = gitRevSync.isDirty(); export class HttpException extends Error { public readonly status?: number; @@ -18,33 +22,84 @@ export class HttpException extends Error { } const app = express(); +const hbs = create({ + helpers: { + add(a: number, b: number) { return a + b; }, + arrayJoin(array: string[], separator: string) { return array.join(separator); }, + formatDuration(duration: number) { return formatDuration(duration); }, + gitRev() { return rev; }, + gitDirty() { return dirty; }, + greaterThan(a: number, b: number) { return a > b; }, + mapNumberToLetter(num: number) { return String.fromCharCode(num + 64); } // A = 1, B = 2 + } +}); app.set("trust proxy", ["loopback", "uniquelocal"]); -app.engine("handlebars", engine()); +app.engine("handlebars", hbs.engine); app.set("view engine", "handlebars"); app.set("views", "./views"); -app.use("/", express.static("public")); +app.use("/", express.static("./public")); +app.get("/favicon.ico", (_req, res) => { res.status(301).location("/favicon.png").send(); }); -app.use(dlTrackMetadata); -app.use(dlWebplayback); -app.use(getTrackMetadata); -app.use(search); +back.forEach((route) => { app.use("/api", route); }); +front.forEach((route) => { app.use(route); }); 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) { +// 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}`; + + next(new HttpException(status, message)); + } else { + next(err); + } +}); + +// make more readable zod error messages +// helps a lot imo +app.use((err: ZodError, req: Request, res: Response, next: NextFunction) => { + if (err instanceof ZodError) { + const formattedErr = fromZodError(err); + + const status = 400; + const message = formattedErr.message; + + if (req.originalUrl.startsWith("/api/")) { + res.status(status).send(message); + } else { + next(new HttpException(status, message)); + } + } else { + next(err); + } +}); + +app.use((err: HttpException, req: Request, res: Response, _next: NextFunction) => { + if (!err.status || (err.status >= 500 && err.status < 600)) { + log.error("internal server error"); log.error(err); } const status = err.status ?? 500; const message = err.message; - res.status(status).send(message); + if (req.originalUrl.startsWith("/api/")) { + res.status(status).send(message); + } else { + res.status(status).render("error", { + title: "uh oh..", + status: status, + message: message + }); + } }); export { app }; diff --git a/src/web/validate.ts b/src/web/validate.ts new file mode 100644 index 0000000..615b0d4 --- /dev/null +++ b/src/web/validate.ts @@ -0,0 +1,7 @@ +import express from "express"; +import { z, ZodSchema } from "zod"; + +export async function validate(req: express.Request, schema: T): Promise> { + const result = await schema.parseAsync(req); + return result; +} diff --git a/tsconfig.json b/tsconfig.json index e06a391..157e0fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,11 +9,9 @@ "verbatimModuleSyntax": true, "esModuleInterop": true, "skipLibCheck": true, + "sourceMap": true, "baseUrl": "src", - "outDir": "dist", - // needed for `source-map-support` - // which adds source maps to stack traces - "sourceMap": true + "outDir": "dist" }, "include": ["**/*"], "exclude": ["dist", "result", "node_modules"] // result is from nix build diff --git a/views/download.handlebars b/views/download.handlebars new file mode 100644 index 0000000..d22363e --- /dev/null +++ b/views/download.handlebars @@ -0,0 +1,9 @@ +
+ + + +
diff --git a/views/error.handlebars b/views/error.handlebars new file mode 100644 index 0000000..a0d2be9 --- /dev/null +++ b/views/error.handlebars @@ -0,0 +1,3 @@ +

{{status}}

+

{{message}}

+

would you like to go back to the main page?

diff --git a/views/index.handlebars b/views/index.handlebars deleted file mode 100644 index 49e8431..0000000 --- a/views/index.handlebars +++ /dev/null @@ -1 +0,0 @@ -{{> search}} diff --git a/views/layouts/main.handlebars b/views/layouts/main.handlebars index fee94d1..67aecc0 100644 --- a/views/layouts/main.handlebars +++ b/views/layouts/main.handlebars @@ -1,12 +1,16 @@ - + amdl - {{title}} + + + + {{> header}} diff --git a/views/partials/download.handlebars b/views/partials/download.handlebars new file mode 100644 index 0000000..ebd43be --- /dev/null +++ b/views/partials/download.handlebars @@ -0,0 +1,5 @@ +{{#if id}} + dl +{{else}} + dl +{{/if}} diff --git a/views/partials/footer.handlebars b/views/partials/footer.handlebars index fa11963..a1a5b2f 100644 --- a/views/partials/footer.handlebars +++ b/views/partials/footer.handlebars @@ -1,5 +1,5 @@ diff --git a/views/partials/paginator.handlebars b/views/partials/paginator.handlebars new file mode 100644 index 0000000..736d4cb --- /dev/null +++ b/views/partials/paginator.handlebars @@ -0,0 +1,13 @@ +
+ {{#if (greaterThan page 1)}} + « + {{else}} + « + {{/if}} + {{page}} + {{#if next}} + » + {{else}} + » + {{/if}} +
diff --git a/views/partials/result.handlebars b/views/partials/result.handlebars index 903653c..0e3d8cd 100644 --- a/views/partials/result.handlebars +++ b/views/partials/result.handlebars @@ -1,11 +1,15 @@ -
+
  • - -
    + +

    {{name}}

    - {{#each artists as |artist|}} - {{artist}}{{#unless @last}},{{/unless}} - {{/each}} + {{arrayJoin artists ", "}}
    -
    +
    +
      + {{#each tracks as |track|}} + {{> track num=(add @index 1)}} + {{/each}} +
    +
  • diff --git a/views/partials/search.handlebars b/views/partials/search.handlebars deleted file mode 100644 index cd94fdd..0000000 --- a/views/partials/search.handlebars +++ /dev/null @@ -1,3 +0,0 @@ -
    - -
    diff --git a/views/partials/track.handlebars b/views/partials/track.handlebars new file mode 100644 index 0000000..a78f0b0 --- /dev/null +++ b/views/partials/track.handlebars @@ -0,0 +1,13 @@ +
  • + + {{mapNumberToLetter discNumber}}{{trackNumber}} + +
    +

    {{name}}

    + {{arrayJoin artists ", "}} +
    +
    + {{formatDuration duration}} + {{> download}} +
    +
  • diff --git a/views/search.handlebars b/views/search.handlebars index 6e23baf..4b70b75 100644 --- a/views/search.handlebars +++ b/views/search.handlebars @@ -1,4 +1,15 @@ -{{> search query=query}} -{{#each results as |result|}} - {{> result name=result.name}} -{{/each}} +
    + +
    +{{#if query}} + {{#if results.length}} +
      + {{#each results as |result|}} + {{> result}} + {{/each}} +
    + {{else}} +

    no results found for {{query}}

    + {{/if}} + {{> paginator}} +{{/if}}