it mostly works!
This commit is contained in:
parent
76543fd220
commit
44cd13f10c
52 changed files with 879 additions and 396 deletions
|
@ -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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue