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_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
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
<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)
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"

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)
// 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": [

View file

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

7
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

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

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
// 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<string> {
const indexResponse = await fetch(baseUrl);
const indexBody = await indexResponse.text();
@ -12,7 +12,7 @@ export async function getToken(baseUrl: string): Promise<string> {
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);

View file

@ -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<AlbumAttributesExtensionMap, T[number]>
export type SongAttributes<
T extends SongAttributesExtensionTypes,
// U extends SongAttributesRelationshipTypes
> = {
albumName: string
artistName: string
@ -31,4 +55,3 @@ export type SongAttributes<
workName?: string
}
& 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
export interface Artwork {
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: 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);

View file

@ -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(),

View file

@ -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<string> {
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<void>((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;

View file

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

View file

@ -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<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 {
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<Item> {
const masterPlaylists = m3u8Data.streamRenditions;
const masterPlaylist = masterPlaylists.find((playlist) => {
@ -166,7 +151,7 @@ async function getPlaylist(m3u8Data: M3u8, codec: RegularCodec): Promise<Item> {
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;
}

View file

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

View file

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

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

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

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

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,
"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

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>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/styles/normalize.css">
<link rel="stylesheet" href="/styles/main.css">
<link rel="icon" href="/favicon.png" type="image/png">
<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>
<body>
{{> 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>
<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;
<a href="https://reidlab.pink/socials" target="_blank">need to contact me?</a>
</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">
<img src="{{cover}}" loading="lazy" decoding="async"/>
<div class="result-text">
<img width="256" height="256" src="{{cover}}" loading="lazy" decoding="async"/>
<div class="result-info-text">
<h2>{{name}}</h2>
<span class="light">{{#each artists as |artist|}}
{{artist}}{{#unless @last}},{{/unless}}
{{/each}}</span>
<span class="light">{{arrayJoin artists ", "}}</span>
</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}}
{{#each results as |result|}}
{{> result name=result.name}}
{{/each}}
<form action="/" method="get">
<input type="search" name="q" placeholder="search for something" value="{{query}}">
</form>
{{#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}}