it mostly works!

This commit is contained in:
Reid 2025-05-12 23:33:52 -07:00
parent 76543fd220
commit 44cd13f10c
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
52 changed files with 879 additions and 396 deletions

View file

@ -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_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 (<img src="./docs/true.png" alt="robert downey jr. true image" height="13">) can pull through. this is also in base64 (`cat private_key.pem | base64 -w 0`)
### config ### 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 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 ## 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: guaranteed formats to work include:
- aac-legacy - aac-legacy
- aac-he-legacy - aac-he-legacy
## screenshots
<details>
<summary>screenshots</summary>
![home screen](./docs/screen-home.png)
![search screen](./docs/screen-search.png)
![download screen](./docs/screen-dl.png)
</details>

View file

@ -3,6 +3,14 @@
# or a unix socket path (e.g. /tmp/sock) # or a unix socket path (e.g. /tmp/sock)
port = 2000 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] [downloader]
# path to the ffmpeg binary # path to the ffmpeg binary
# will get from PATH if simply "ffmpeg" # will get from PATH if simply "ffmpeg"

BIN
docs/screen-dl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

BIN
docs/screen-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
docs/screen-search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

BIN
docs/true.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -20,6 +20,7 @@ export default [
// not very typescript-coded (typescript shouldnt require runtime debugging, thats the point of it) // not very typescript-coded (typescript shouldnt require runtime debugging, thats the point of it)
// TODO: find a rule to make seperators on interfaces consistent // 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/explicit-function-return-type": "error",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [

View file

@ -22,8 +22,8 @@
# uncomment this and let the build fail, then get the current hash # uncomment this and let the build fail, then get the current hash
# very scuffed but endorsed! # very scuffed but endorsed!
#npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
npmDepsHash = "sha256-XCGUKgLZxW7MonHswkp7mbvgeUlRCgBE3WnRjElf44Q="; npmDepsHash = "sha256-f+RacjhkJP3RlK+yKJ8Xm0Rar4NyIxJjNQYDrpqhnD4=";
installPhase = '' installPhase = ''
mkdir -p $out mkdir -p $out

7
package-lock.json generated
View file

@ -16,6 +16,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"express-handlebars": "^8.0.1", "express-handlebars": "^8.0.1",
"format-duration": "^3.0.2",
"git-rev-sync": "^3.0.2", "git-rev-sync": "^3.0.2",
"node-widevine": "https://github.com/wangziyingwen/node-widevine", "node-widevine": "https://github.com/wangziyingwen/node-widevine",
"parse-hls": "^1.0.7", "parse-hls": "^1.0.7",
@ -2073,6 +2074,12 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",

View file

@ -19,6 +19,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"express-handlebars": "^8.0.1", "express-handlebars": "^8.0.1",
"format-duration": "^3.0.2",
"git-rev-sync": "^3.0.2", "git-rev-sync": "^3.0.2",
"node-widevine": "https://github.com/wangziyingwen/node-widevine", "node-widevine": "https://github.com/wangziyingwen/node-widevine",
"parse-hls": "^1.0.7", "parse-hls": "^1.0.7",

View file

@ -10,6 +10,7 @@
--foreground-muted: #a6adc8; --foreground-muted: #a6adc8;
--background: #1e1e2e; --background: #1e1e2e;
--background-surface: #313244; --background-surface: #313244;
--background-surface-muted: #45475a;
--shadow: #11111b; --shadow: #11111b;
} }
@ -30,9 +31,11 @@ main {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-left: auto;
margin-right: auto;
gap: 1em; gap: 1em;
padding: 1em; padding: 1em;
width: 100%; width: 60%;
height: 100%; height: 100%;
} }
@ -41,39 +44,49 @@ h1, h2, h3, h4, h5, h6, p {
margin-bottom: .8em; margin-bottom: .8em;
} }
/* this isn't great in terms of accessibility */ p {
/* makes sense for most the stuff we got tho */ text-align: center;
}
a { a {
color: var(--foreground); color: var(--foreground);
text-decoration: none; text-decoration: underline dotted;
} }
a:hover { a:hover {
color: var(--foreground-muted); color: var(--foreground-muted);
text-decoration: underline; text-decoration: underline;
} }
.light { hr {
color: var(--foreground-muted); width: 100%;
height: 1px;
background-color: var(--background-surface-muted);
border: none;
margin: 0.25em 0;
} }
input::placeholder { input::placeholder {
color: var(--foreground-muted); color: var(--foreground-muted);
} }
input[type="search"] { input[type="search"], input[type="submit"], select {
color: var(--foreground); color: var(--foreground);
background-color: var(--background-surface); background-color: var(--background-surface);
border: 0; border: 0;
border-radius: 0.5em; border-radius: 0.5em;
padding: 0.5em 1em; padding: 0.5em 1em;
box-shadow: 0 0 2em var(--shadow); box-shadow: 0 0 1em var(--shadow);
}
input[type="submit"] {
cursor: pointer;
} }
header { header {
background-color: var(--background-surface); background-color: var(--background-surface);
padding-left: 1em; padding-left: 1em;
padding-right: 1em; padding-right: 1em;
box-shadow: 0 0 2em var(--shadow); box-shadow: 0 0 1em var(--shadow);
} }
header h1 { header h1 {
font-size: 1.2em; font-size: 1.2em;
@ -82,35 +95,125 @@ header h1 {
footer { footer {
background-color: var(--background-surface); background-color: var(--background-surface);
padding: 1em; 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 { .result {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 60%;
gap: 1em; gap: 1em;
padding: 1em; padding: 1em;
background-color: var(--background-surface); background-color: var(--background-surface);
box-shadow: 0 0 2em var(--shadow); box-shadow: 0 0 1em var(--shadow);
border-radius: 0.5em; border-radius: 0.5em;
width: 100%;
} }
.result-info { .result-info {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
gap: 1em; gap: 1em;
} }
.result-info img { .result-info img {
width: 5.5em; width: 4em;
height: 5.5em; height: 4em;
border-radius: 0.5em; 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; display: flex;
flex-direction: column; flex-direction: column;
} }
.result-text h2 { .result-info-text h2 {
font-size: 1em; font-size: 1em;
margin-top: 0; margin-top: 0;
margin-bottom: 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%;
}
}

View file

@ -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<void> {
this.http.defaults.headers.common["Authorization"] = `Bearer ${await getToken(appleMusicHomepageUrl)}`;
}
async getSong<
T extends SongAttributesExtensionTypes = ["extendedAssetUrls"]
> (
id: string,
extend: T = ["extendedAssetUrls"] as T
): Promise<GetSongResponse<T>> {
return (await this.http.get<GetSongResponse<T>>(`/v1/catalog/${this.storefront}/songs/${id}`, {
params: {
extend: extend.join(",")
}
})).data;
}
async getWebplayback(
trackId: string
): Promise<WebplaybackResponse> {
return (await this.http.post(webplaybackApiUrl, {
salableAdamId: trackId,
language: config.downloader.api.language
})).data;
}
async getWidevineLicense(
trackId: string,
trackUri: string,
challenge: string
): Promise<WidevineLicenseResponse> {
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 };

View file

@ -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);

View file

@ -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
}
});
}
}

View file

@ -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
}
}

View file

@ -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<T>
// TODO: add relationships
// https://developer.apple.com/documentation/applemusicapi/songs/relationships-data.dictionary
}[]
}

163
src/appleMusicApi/index.ts Normal file
View file

@ -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<void> {
this.http.defaults.headers.common["Authorization"] = `Bearer ${await getToken(appleMusicHomepageUrl)}`;
}
async getAlbum<
T extends AlbumAttributesExtensionTypes = [],
U extends RelationshipTypes<T> = ["tracks"]
> (
id: string,
extend: T = [] as unknown[] as T,
relationships: U = ["tracks"] as U
): Promise<GetSongResponse<T, U>> {
return (await this.http.get<GetSongResponse<T, U>>(`/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<T> = ["albums"]
> (
id: string,
extend: T = ["extendedAssetUrls"] as T,
relationships: U = ["albums"] as U
): Promise<GetSongResponse<T, U>> {
return (await this.http.get<GetSongResponse<T, U>>(`/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<T> = ["tracks"]
> (
term: string,
limit: number = 25,
offset: number = 0,
extend: T = [] as unknown[] as T,
relationships: U = ["tracks"] as U
): Promise<SearchResponse<T, U>> {
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<WebplaybackResponse> {
// 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<WidevineLicenseResponse> {
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 };

View file

@ -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 // 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" // 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 // 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<string> { export async function getToken(baseUrl: string): Promise<string> {
const indexResponse = await fetch(baseUrl); const indexResponse = await fetch(baseUrl);
const indexBody = await indexResponse.text(); const indexBody = await indexResponse.text();
@ -12,7 +12,7 @@ export async function getToken(baseUrl: string): Promise<string> {
const jsPath = indexBody.match(jsRegex)?.[0]; const jsPath = indexBody.match(jsRegex)?.[0];
if (!jsPath) { 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); const jsResponse = await fetch(baseUrl + jsPath);

View file

@ -1,10 +1,34 @@
import type { SongAttributesExtensionMap, SongAttributesExtensionTypes } from "./extensions.js";
import type { Artwork, EditorialNotes, PlayParameters, Preview } from "./extras.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<AlbumAttributesExtensionMap, T[number]>
export type SongAttributes< export type SongAttributes<
T extends SongAttributesExtensionTypes, T extends SongAttributesExtensionTypes,
// U extends SongAttributesRelationshipTypes
> = { > = {
albumName: string albumName: string
artistName: string artistName: string
@ -31,4 +55,3 @@ export type SongAttributes<
workName?: string workName?: string
} }
& Pick<SongAttributesExtensionMap, T[number]> & Pick<SongAttributesExtensionMap, T[number]>
// & Pick<SongAttributesRelationshipMap, U[number]>

View file

@ -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
}
}

View file

@ -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 // https://developer.apple.com/documentation/applemusicapi/artwork
export interface Artwork { export interface Artwork {
bgColor?: string bgColor?: string

View file

@ -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<T> = {
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<T extends AnyAttributesExtensionTypes> = keyof RelationshipTypeMap<T>;
export type RelationshipTypes<T extends AnyAttributesExtensionTypes> = RelationshipType<T>[];
export type RelationshipTypeMap<T extends AnyAttributesExtensionTypes> = {
albums: AlbumAttributes<Extract<T, AlbumAttributesExtensionTypes>>,
tracks: SongAttributes<Extract<T, SongAttributesExtensionTypes>> // TODO: tracks can also be music videos, uh oh.
}

View file

@ -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<T>
> {
// 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<T>,
// https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary
relationships: {
[K in U[number]]: Relationship<
K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never
>
}
}[]
}
// https://developer.apple.com/documentation/applemusicapi/get-a-catalog-song
export interface GetSongResponse<
T extends SongAttributesExtensionTypes,
U extends RelationshipTypes<T>
> {
// https://developer.apple.com/documentation/applemusicapi/songs
data: {
id: string
type: "songs"
href: string
// https://developer.apple.com/documentation/applemusicapi/songs/attributes-data.dictionary
attributes: SongAttributes<T>
// https://developer.apple.com/documentation/applemusicapi/songs/relationships-data.dictionary
relationships: {
[K in U[number]]: Relationship<
K extends RelationshipType<T> ? RelationshipTypeMap<T>[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<T>
> {
// 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<Extract<T, AlbumAttributesExtensionTypes>>,
// https://developer.apple.com/documentation/applemusicapi/albums/relationships-data.dictionary
relationships: {
[K in U[number]]: Relationship<
K extends RelationshipType<T> ? RelationshipTypeMap<T>[K] : never
>
}
}[],
href?: string,
next?: string,
}
}
}

View file

@ -11,6 +11,7 @@ import * as log from "./log.js";
// TODO: swap to sqlite // TODO: swap to sqlite
// TODO: make async fs calls // TODO: make async fs calls
// TODO: rework EVERYTHING // TODO: rework EVERYTHING
// TODO: refresh cache timer on download
interface CacheEntry { interface CacheEntry {
fileName: string; fileName: string;
@ -93,6 +94,3 @@ export function addToCache(fileName: string): void {
}); });
rewriteCache(); rewriteCache();
} }
// setTimeout(() => addToCache("jorry.tx"), 1000);

View file

@ -9,7 +9,11 @@ dotenv.config();
const configSchema = z.object({ const configSchema = z.object({
server: 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({ downloader: z.object({
ffmpeg_path: z.string(), ffmpeg_path: z.string(),

View file

@ -3,11 +3,9 @@ import { spawn } from "node:child_process";
import path from "node:path"; import path from "node:path";
import { addToCache, isCached } from "../cache.js"; 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<string> { export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodec | WebplaybackCodec): Promise<string> {
let baseOutputName = streamUrl.split("/").at(-1)?.split("?").at(0)?.split(".").splice(0, 1).join(".")?.trim(); let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1];
if (!baseOutputName) { throw "could not get base output name from stream url"; } if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); }
baseOutputName += `_${songCodec}`; baseOutputName += `_${songCodec}`;
const encryptedName = baseOutputName + "_enc.mp4"; const encryptedName = baseOutputName + "_enc.mp4";
const encryptedPath = path.join(config.downloader.cache.directory, encryptedName); 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, "--paths", config.downloader.cache.directory,
"--output", encryptedName, "--output", encryptedName,
streamUrl 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(); }); child.on("exit", () => { res(); });
}); });
addToCache(encryptedName);
await new Promise<void>((res, rej) => { await new Promise<void>((res, rej) => {
const child = spawn(config.downloader.ffmpeg_path, [ const child = spawn(config.downloader.ffmpeg_path, [
"-loglevel", "error", "-loglevel", "error",
@ -42,12 +43,12 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son
"-c", "copy", "-c", "copy",
"-movflags", "+faststart", "-movflags", "+faststart",
decryptedPath 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(); } ); child.on("exit", () => { res(); } );
}); });
addToCache(encryptedName);
addToCache(decryptedName); addToCache(decryptedName);
return decryptedPath; return decryptedPath;

View file

@ -1,6 +1,6 @@
import { LicenseType, Session } from "node-widevine"; import { LicenseType, Session } from "node-widevine";
import { env } from "../config.js"; 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 { dataUriToBuffer } from "data-uri-to-buffer";
import psshTools from "pssh-tools"; import psshTools from "pssh-tools";
import * as log from "../log.js"; 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 // 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 // well, somewhat. it's just the raw data, we have to rebuild the pssh
const rebuiltPssh = psshTools.widevine.encodePssh({ 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, dataOnly: false,
keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")] keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")]
}); });
@ -38,11 +38,11 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str
challenge.toString("base64") 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")); 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; 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; return validKey;
} }

View file

@ -1,30 +1,16 @@
import * as log from "../log.js"; 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 hls, { Item } from "parse-hls";
import axios from "axios"; 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 "api/appleMusicApi.js"; import type { WebplaybackResponse } from "appleMusicApi/index.js";
import { RegularCodec, WebplaybackCodec } from "./index.js"; import { RegularCodec, WebplaybackCodec } from "./index.js";
// 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:
type M3u8 = ReturnType<typeof hls.default.parse>; type M3u8 = ReturnType<typeof hls.default.parse>;
// TODO: whole big thing, and a somewhat big issue
// some files can just Not be downloaded
// this is because only the fairplay (com.apple.streamingkeydelivery) key is present
// and no other drm schemes exist..
// however... there is still widevine ones ???? just tucked away... REALLY WELL
// https://github.com/glomatico/gamdl/blob/main/gamdl/downloader_song_legacy.py#L27
// bullshit, i tell you.
// havent had this issue with the small pool i tested before late 2024. what ????
// i don't get it.
// i just tried another thing from 2022 ro 2023 and it worked fine
// 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 { export default class StreamInfo {
public readonly trackId: string; public readonly trackId: string;
public readonly streamUrl: string; public readonly streamUrl: string;
@ -60,20 +46,22 @@ export default class StreamInfo {
const assetInfos = getAssetInfos(m3u8Parsed); const assetInfos = getAssetInfos(m3u8Parsed);
const playlist = await getPlaylist(m3u8Parsed, codec); const playlist = await getPlaylist(m3u8Parsed, codec);
const variantId = playlist.properties[0].attributes.stableVariantId; const variantId = playlist.properties[0].attributes.stableVariantId;
if (variantId === undefined) { throw "variant id does not exist!"; } if (variantId === undefined) { throw new Error("variant id does not exist!"); }
if (typeof variantId !== "string") { throw "variant id is not a string!"; } if (typeof variantId !== "string") { throw new Error("variant id is not a string!"); }
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 widevinePssh = getWidevinePssh(drmInfos, drmIds); const widevinePssh = getWidevinePssh(drmInfos, drmIds);
const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds); const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds);
const fairplayKey = getFairplayKey(drmInfos, drmIds); const fairplayKey = getFairplayKey(drmInfos, drmIds);
const trackId = trackMetadata.playParams?.id; 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( return new StreamInfo(
trackId, trackId,
m3u8Url, // TODO: make this keep in mind the CODEC, yt-dlp will shit itself if not supplied i think correctM3u8Url,
widevinePssh, widevinePssh,
playreadyPssh, playreadyPssh,
fairplayKey fairplayKey
@ -90,7 +78,7 @@ export default class StreamInfo {
else if (codec === WebplaybackCodec.AacLegacy) { flavor = "28:ctrp256"; } else if (codec === WebplaybackCodec.AacLegacy) { flavor = "28:ctrp256"; }
const asset = song.assets.find((asset) => { return asset.flavor === flavor; }); 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; const trackId = song.songId;
@ -99,8 +87,8 @@ export default class StreamInfo {
const m3u8Parsed = hls.default.parse(m3u8.data); const m3u8Parsed = hls.default.parse(m3u8.data);
const widevinePssh = m3u8Parsed.lines.find((line) => { return line.name === "key"; })?.attributes?.uri; const widevinePssh = m3u8Parsed.lines.find((line) => { return line.name === "key"; })?.attributes?.uri;
if (widevinePssh === undefined) { throw "widevine uri is missing!"; } if (widevinePssh === undefined) { throw new Error("widevine uri is missing!"); }
if (typeof widevinePssh !== "string") { throw "widevine uri is not a string!"; } if (typeof widevinePssh !== "string") { throw new Error("widevine uri is not a string!"); }
// afaik this ONLY has widevine // afaik this ONLY has widevine
return new StreamInfo( return new StreamInfo(
@ -122,14 +110,14 @@ function getDrmInfos(m3u8Data: M3u8): DrmInfos {
line.content.includes("com.apple.hls.AudioSessionKeyInfo") line.content.includes("com.apple.hls.AudioSessionKeyInfo")
) { ) {
const value = line.content.match(/VALUE="([^"]+)"/); const value = line.content.match(/VALUE="([^"]+)"/);
if (!value) { throw "could not match for drm key value!"; } if (!value) { throw new Error("could not match for drm key value!"); }
if (!value[1]) { throw "drm key value is empty!"; } if (!value[1]) { throw new Error("drm key value is empty!"); }
return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8")); 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[]; }; } 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") line.content.includes("com.apple.hls.audioAssetMetadata")
) { ) {
const value = line.content.match(/VALUE="([^"]+)"/); const value = line.content.match(/VALUE="([^"]+)"/);
if (!value) { throw "could not match for value!"; } if (!value) { throw new Error("could not match for value!"); }
if (!value[1]) { throw "value is empty!"; } if (!value[1]) { throw new Error("value is empty!"); }
return JSON.parse(Buffer.from(value[1], "base64").toString("utf-8")); 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<Item> { async function getPlaylist(m3u8Data: M3u8, codec: RegularCodec): Promise<Item> {
const masterPlaylists = m3u8Data.streamRenditions; const masterPlaylists = m3u8Data.streamRenditions;
const masterPlaylist = masterPlaylists.find((playlist) => { const masterPlaylist = masterPlaylists.find((playlist) => {
@ -166,7 +151,7 @@ async function getPlaylist(m3u8Data: M3u8, codec: RegularCodec): Promise<Item> {
return match !== null; 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; return masterPlaylist;
} }

View file

@ -1,7 +1,7 @@
import { config } from "./config.js"; import { config } from "./config.js";
import process from "node:process"; import process from "node:process";
import * as log from "./log.js"; import * as log from "./log.js";
import { appleMusicApi } from "./api/index.js"; import { appleMusicApi } from "./appleMusicApi/index.js";
import { app } from "./web/index.js"; import { app } from "./web/index.js";
await appleMusicApi.login().catch((err) => { await appleMusicApi.login().catch((err) => {
@ -39,8 +39,3 @@ process.on("unhandledRejection", (err) => {
log.error(err); log.error(err);
process.exit(1); process.exit(1);
}); });
// TODO: remove later
// this is for testing purposes
await import("./downloader/streamInfo.js");
await import("./cache.js");

View file

@ -6,11 +6,10 @@ import callsites from "callsites";
import sourceMapSupport from "source-map-support"; import sourceMapSupport from "source-map-support";
import { fileURLToPath } from "node:url"; 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(); sourceMapSupport.install();
enum Level { enum Level {
Http,
Debug, Debug,
Info, Info,
Warn, Warn,
@ -18,12 +17,14 @@ enum Level {
} }
const levelColors = { const levelColors = {
[Level.Http]: chalk.gray,
[Level.Debug]: chalk.blue, [Level.Debug]: chalk.blue,
[Level.Info]: chalk.green, [Level.Info]: chalk.green,
[Level.Warn]: chalk.yellow, [Level.Warn]: chalk.yellow,
[Level.Error]: chalk.red [Level.Error]: chalk.red
}; };
const levelNames = { const levelNames = {
[Level.Http]: "HTTP",
[Level.Debug]: "DEBUG", [Level.Debug]: "DEBUG",
[Level.Info]: "INFO", [Level.Info]: "INFO",
[Level.Warn]: "WARN", [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`); 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 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); }

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -1,20 +1,26 @@
import { appleMusicApi } from "../../../api/index.js"; import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express"; import express from "express";
import { validate } from "../../validate.js";
import { z } from "zod";
const router = express.Router(); const router = express.Router();
const schema = z.object({
query: z.object({
id: z.string()
})
});
// this endpoint isn't actually used for anything by us // 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 // it makes it a bit easier to get the metadata for a track knowing the trackId
router.get("/getTrackMetadata", async (req, res, next) => { router.get("/getTrackMetadata", async (req, res, next) => {
try { try {
const { trackId } = req.query; const { id } = (await validate(req, schema)).query;
if (typeof trackId !== "string") { res.status(400).send("trackId is required and must be a string!"); return; }
const trackMetadata = await appleMusicApi.getSong(trackId); const trackMetadata = await appleMusicApi.getSong(id);
const trackAttributes = trackMetadata.data[0].attributes;
res.json(trackAttributes); res.json(trackMetadata);
} catch (err) { } catch (err) {
next(err); next(err);
} }

View file

@ -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;

View file

@ -1,45 +1,66 @@
import express from "express"; import express from "express";
import gitRepoInfo from "git-rev-sync"; import { validate } from "../../validate.js";
import { z } from "zod";
// TODO: move this into a helper or whatever? import { appleMusicApi } from "../../../appleMusicApi/index.js";
// i don't wanna do this for every single page lol import { config } from "../../../config.js";
const hash = gitRepoInfo.short("./"); import queryString from "node:querystring";
const dirty = gitRepoInfo.isDirty();
const router = express.Router(); const router = express.Router();
// TODO: implement this const schema = z.object({
// TODO: show tracks query: z.object({
// TODO: add a download button q: z.optional(z.string()),
router.get("/", (req, res) => { 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", { res.render("search", {
title: "search", title: "search",
hash: hash, query: q,
dirty: dirty, page: page ?? 1,
query: req.query.q, back: req.path + "?" + queryString.stringify({ q: q, page: (page ?? 1) - 1 }),
results: [ next: (albums?.next !== undefined && req.path + "?" + queryString.stringify({ q: q, page: (page ?? 1) + 1 })) || undefined,
{ results: albums?.data.map((result) => {
name: "Revengeseekerz", const { artistName, artwork, name } = result.attributes;
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" // use 220x220 cover, it's what the real apple music uses for larger sizes on the <picture> 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
// name: "Carousel (An Examination of the Shadow, Creekflow, And its Life as an Afterthought) ", // in fact: `x-cache-hits` is greater than 0, sometimes :)
// artists: ["Vylet Pony"], const cover = artwork.url.replace("{w}", "220").replace("{h}", "220");
// tracks: [ const tracks = result.relationships.tracks.data;
// {
// artists: ["Vylet Pony"], return {
// name: "Carousel" name: name,
// }, artists: [artistName],
// { cover: cover,
// artists: ["Vylet Pony", "Namii"], tracks: tracks.map((track) => {
// name: "The Shadow" const { artistName, name, durationInMillis, discNumber, trackNumber } = track.attributes;
// }
// ], return {
// cover: "https://is1-ssl.mzstatic.com/image/thumb/Music116/v4/7c/f0/94/7cf09429-4942-a9cb-1287-b8bbc53a4d61/artwork.jpg/592x592bb.webp" discNumber: discNumber,
// } trackNumber: trackNumber,
] name: name,
artists: [artistName],
duration: durationInMillis,
cover: cover,
id: track.attributes.playParams?.id
};
})
};
})
}); });
} catch (err) {
next(err);
}
}); });
export default router; export default router;

View file

@ -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
];

View file

@ -1,11 +1,15 @@
import * as log from "../log.js"; import * as log from "../log.js";
import express, { type NextFunction, type Request, type Response } from "express"; 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"; const rev = gitRevSync.short("./");
import dlWebplayback from "./endpoints/back/dlWebplayback.js"; const dirty = gitRevSync.isDirty();
import getTrackMetadata from "./endpoints/back/getTrackMetadata.js";
import search from "./endpoints/front/search.js";
export class HttpException extends Error { export class HttpException extends Error {
public readonly status?: number; public readonly status?: number;
@ -18,33 +22,84 @@ export class HttpException extends Error {
} }
const app = express(); 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.set("trust proxy", ["loopback", "uniquelocal"]);
app.engine("handlebars", engine()); app.engine("handlebars", hbs.engine);
app.set("view engine", "handlebars"); app.set("view engine", "handlebars");
app.set("views", "./views"); 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); back.forEach((route) => { app.use("/api", route); });
app.use(dlWebplayback); front.forEach((route) => { app.use(route); });
app.use(getTrackMetadata);
app.use(search);
app.use((req, _res, next) => { app.use((req, _res, next) => {
next(new HttpException(404, `${req.path} not found`)); next(new HttpException(404, `${req.path} not found`));
}); });
app.use((err: HttpException, _req: Request, res: Response, _next: NextFunction) => { // ex. if the apple music api returns a 403, we want to return a 403
if (!err.status || err.status % 500 < 100) { // 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); log.error(err);
} }
const status = err.status ?? 500; const status = err.status ?? 500;
const message = err.message; const message = err.message;
if (req.originalUrl.startsWith("/api/")) {
res.status(status).send(message); res.status(status).send(message);
} else {
res.status(status).render("error", {
title: "uh oh..",
status: status,
message: message
});
}
}); });
export { app }; export { app };

7
src/web/validate.ts Normal file
View file

@ -0,0 +1,7 @@
import express from "express";
import { z, ZodSchema } from "zod";
export async function validate<T extends ZodSchema>(req: express.Request, schema: T): Promise<z.infer<T>> {
const result = await schema.parseAsync(req);
return result;
}

View file

@ -9,11 +9,9 @@
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true,
"baseUrl": "src", "baseUrl": "src",
"outDir": "dist", "outDir": "dist"
// needed for `source-map-support`
// which adds source maps to stack traces
"sourceMap": true
}, },
"include": ["**/*"], "include": ["**/*"],
"exclude": ["dist", "result", "node_modules"] // result is from nix build "exclude": ["dist", "result", "node_modules"] // result is from nix build

View file

@ -0,0 +1,9 @@
<form class="download-form" action="/api/download" method="get">
<select name="codec">
{{#each codecs as |codec|}}
<option value="{{codec}}">{{codec}}</option>
{{/each}}
</select>
<input type="hidden" name="id" value="{{id}}">
<input type="submit" value="download!">
</form>

3
views/error.handlebars Normal file
View file

@ -0,0 +1,3 @@
<h2 class="error">{{status}}</h2>
<p><samp class="wrap">{{message}}</samp></p>
<p>would you like to <a href="/">go back to the main page?</a></p>

View file

@ -1 +0,0 @@
{{> search}}

View file

@ -1,12 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/styles/normalize.css"> <link rel="stylesheet" href="/styles/normalize.css">
<link rel="stylesheet" href="/styles/main.css"> <link rel="stylesheet" href="/styles/main.css">
<link rel="icon" href="/favicon.png" type="image/png"> <link rel="icon" href="/favicon.png" type="image/png">
<title>amdl - {{title}}</title> <title>amdl - {{title}}</title>
<meta name="description" content="world's first(?) public(?) apple music downloader">
<meta property="og:type" content="website">
<meta property="og:title" content="amdl - {{title}}">
<meta property="og:description" content="world's first(?) public(?) apple music downloader">
</head> </head>
<body> <body>
{{> header}} {{> header}}

View file

@ -0,0 +1,5 @@
{{#if id}}
<a href="/download?id={{id}}">dl</a>
{{else}}
<span class="light">dl</span>
{{/if}}

View file

@ -1,5 +1,5 @@
<footer> <footer>
<a href="https://git.reidlab.pink/reidlab/amdl" target="_blank">source [<code>{{hash}}{{#if dirty}}-dirty{{/if}}</code>]</a> <a href="https://git.reidlab.pink/reidlab/amdl" target="_blank">source (<code>{{gitRev}}{{#if (gitDirty)}}-dirty{{/if}}</code>)</a>
&middot; &middot;
<a href="https://reidlab.pink/socials" target="_blank">need to contact me?</a> <a href="https://reidlab.pink/socials" target="_blank">need to contact me?</a>
</footer> </footer>

View file

@ -0,0 +1,13 @@
<div class="paginator">
{{#if (greaterThan page 1)}}
<a href="{{back}}">&laquo;</a>
{{else}}
<span class="light">&laquo;</span>
{{/if}}
<span>{{page}}</span>
{{#if next}}
<a href="{{next}}">&raquo;</a>
{{else}}
<span class="light">&raquo;</span>
{{/if}}
</div>

View file

@ -1,11 +1,15 @@
<div class="result"> <li class="result">
<div class="result-info"> <div class="result-info">
<img src="{{cover}}" loading="lazy" decoding="async"/> <img width="256" height="256" src="{{cover}}" loading="lazy" decoding="async"/>
<div class="result-text"> <div class="result-info-text">
<h2>{{name}}</h2> <h2>{{name}}</h2>
<span class="light">{{#each artists as |artist|}} <span class="light">{{arrayJoin artists ", "}}</span>
{{artist}}{{#unless @last}},{{/unless}}
{{/each}}</span>
</div> </div>
</div> </div>
</div> <hr>
<ol class="result-tracklist">
{{#each tracks as |track|}}
{{> track num=(add @index 1)}}
{{/each}}
</ol>
</li>

View file

@ -1,3 +0,0 @@
<form action="/" method="get">
<input type="search" name="q" placeholder="search for something" value="{{query}}">
</form>

View file

@ -0,0 +1,13 @@
<li class="track">
<code class="track-number">
<span class="light">{{mapNumberToLetter discNumber}}{{trackNumber}}</span>
</code>
<div class="track-info">
<h3>{{name}}</h3>
<span class="light">{{arrayJoin artists ", "}}</span>
</div>
<div class="track-right">
<code class="track-time"><span class="light">{{formatDuration duration}}</span></code>
{{> download}}
</div>
</li>

View file

@ -1,4 +1,15 @@
{{> search query=query}} <form action="/" method="get">
{{#each results as |result|}} <input type="search" name="q" placeholder="search for something" value="{{query}}">
{{> result name=result.name}} </form>
{{/each}} {{#if query}}
{{#if results.length}}
<ul class="results">
{{#each results as |result|}}
{{> result}}
{{/each}}
</ul>
{{else}}
<p>no results found for <samp class="wrap">{{query}}</samp></p>
{{/if}}
{{> paginator}}
{{/if}}