it mostly works!
This commit is contained in:
parent
76543fd220
commit
44cd13f10c
52 changed files with 879 additions and 396 deletions
23
README.md
23
README.md
|
@ -12,17 +12,36 @@ a self-hostable web-ui apple music downloader widevine decryptor with questionab
|
|||
|
||||
`WIDEVINE_CLIENT_ID` is uhm owie. this thing kind of Sucks to obtain and i would totally recommend finding a not-so-legal spot you can obtain this from (in fact, i found one on github LOL), rather than extracting it yourself. if you want to do through the pain like i did, check [this guide](forum.videohelp.com/threads/408031-Dumping-Your-own-L3-CDM-with-Android-Studio) out!! once you have your `client_id.bin` file, convert it to base64 and slap it in the env var (`cat client_id.bin | base64 -w 0`)
|
||||
|
||||
`WIDEVINE_PRIVATE_KEY` is essentially the same process of obtainment, you'll get it from the same guide!! i'm not sure how to easily find one of these on the web, but i'm sure you end users (user count: 0 (TRVTHNVKE)) can pull through. this is also in base64 (`cat private_key.pem | base64 -w 0`)
|
||||
`WIDEVINE_PRIVATE_KEY` is essentially the same process of obtainment, you'll get it from the same guide!! i'm not sure how to easily find one of these on the web, but i'm sure you end users (user count: 0 (<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>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
|
|
@ -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
BIN
docs/screen-dl.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 211 KiB |
BIN
docs/screen-home.png
Normal file
BIN
docs/screen-home.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 143 KiB |
BIN
docs/screen-search.png
Normal file
BIN
docs/screen-search.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 570 KiB |
BIN
docs/true.png
Normal file
BIN
docs/true.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
|
@ -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": [
|
||||
|
|
|
@ -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
7
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 };
|
|
@ -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);
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
163
src/appleMusicApi/index.ts
Normal 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 };
|
|
@ -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);
|
|
@ -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]>
|
23
src/appleMusicApi/types/extensions.ts
Normal file
23
src/appleMusicApi/types/extensions.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
24
src/appleMusicApi/types/relationships.ts
Normal file
24
src/appleMusicApi/types/relationships.ts
Normal 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.
|
||||
}
|
82
src/appleMusicApi/types/responses.ts
Normal file
82
src/appleMusicApi/types/responses.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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); }
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
58
src/web/endpoints/back/download.ts
Normal file
58
src/web/endpoints/back/download.ts
Normal 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;
|
28
src/web/endpoints/back/getAlbumMetadata.ts
Normal file
28
src/web/endpoints/back/getAlbumMetadata.ts
Normal 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;
|
|
@ -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);
|
||||
}
|
||||
|
|
28
src/web/endpoints/front/download.ts
Normal file
28
src/web/endpoints/front/download.ts
Normal 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;
|
|
@ -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;
|
||||
|
|
15
src/web/endpoints/index.ts
Normal file
15
src/web/endpoints/index.ts
Normal 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
|
||||
];
|
|
@ -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
7
src/web/validate.ts
Normal 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;
|
||||
}
|
|
@ -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
|
||||
|
|
9
views/download.handlebars
Normal file
9
views/download.handlebars
Normal 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
3
views/error.handlebars
Normal 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>
|
|
@ -1 +0,0 @@
|
|||
{{> search}}
|
|
@ -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}}
|
||||
|
|
5
views/partials/download.handlebars
Normal file
5
views/partials/download.handlebars
Normal file
|
@ -0,0 +1,5 @@
|
|||
{{#if id}}
|
||||
<a href="/download?id={{id}}">dl</a>
|
||||
{{else}}
|
||||
<span class="light">dl</span>
|
||||
{{/if}}
|
|
@ -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>
|
||||
·
|
||||
<a href="https://reidlab.pink/socials" target="_blank">need to contact me?</a>
|
||||
</footer>
|
||||
|
|
13
views/partials/paginator.handlebars
Normal file
13
views/partials/paginator.handlebars
Normal file
|
@ -0,0 +1,13 @@
|
|||
<div class="paginator">
|
||||
{{#if (greaterThan page 1)}}
|
||||
<a href="{{back}}">«</a>
|
||||
{{else}}
|
||||
<span class="light">«</span>
|
||||
{{/if}}
|
||||
<span>{{page}}</span>
|
||||
{{#if next}}
|
||||
<a href="{{next}}">»</a>
|
||||
{{else}}
|
||||
<span class="light">»</span>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -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>
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
<form action="/" method="get">
|
||||
<input type="search" name="q" placeholder="search for something" value="{{query}}">
|
||||
</form>
|
13
views/partials/track.handlebars
Normal file
13
views/partials/track.handlebars
Normal 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>
|
|
@ -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}}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue