mirror of https://github.com/miraclx/freyr-js
339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
/* eslint-disable camelcase, no-underscore-dangle, class-methods-use-this */
|
|
import xurl from 'url';
|
|
import path from 'path';
|
|
|
|
import Promise from 'bluebird';
|
|
import NodeCache from 'node-cache';
|
|
import {Client} from '@yujinakayama/apple-music';
|
|
|
|
import symbols from '../symbols.js';
|
|
|
|
const validUriTypes = ['track', 'album', 'artist', 'playlist'];
|
|
|
|
export default class AppleMusic {
|
|
static [symbols.meta] = {
|
|
ID: 'apple_music',
|
|
DESC: 'Apple Music',
|
|
PROPS: {
|
|
isQueryable: true,
|
|
isSearchable: false,
|
|
isSourceable: false,
|
|
},
|
|
// https://www.debuggex.com/r/Pv_Prjinkz1m2FOB
|
|
VALID_URL:
|
|
/(?:(?:(?:(?:https?:\/\/)?(?:www\.)?)(?:(?:music|(?:geo\.itunes))\.apple.com)\/([a-z]{2})\/(song|album|artist|playlist)\/(?:([^/]+)\/)?\w+)|(?:apple_music:(track|album|artist|playlist):([\w.]+)))/,
|
|
PROP_SCHEMA: {},
|
|
};
|
|
|
|
[symbols.meta] = AppleMusic[symbols.meta];
|
|
|
|
#store = {
|
|
cache: new NodeCache(),
|
|
core: null,
|
|
defaultStorefront: null,
|
|
isAuthenticated: false,
|
|
};
|
|
|
|
constructor(config) {
|
|
if (!config) throw new Error(`[AppleMusic] Please define a configuration object`);
|
|
if (typeof config !== 'object') throw new Error(`[AppleMusic] Please define a configuration as an object`);
|
|
if (!config.developerToken)
|
|
throw new Error(`[AppleMusic] Please define [developerToken] as a property within the configuration`);
|
|
this.#store.core = new Client({developerToken: config.developerToken});
|
|
for (let instance of [this.#store.core.albums, this.#store.core.artists, this.#store.core.playlists, this.#store.core.songs])
|
|
instance.axiosInstance.interceptors.request.use(conf => ((conf.headers.origin = 'https://music.apple.com'), conf));
|
|
this.#store.defaultStorefront = config.storefront;
|
|
this.#store.isAuthenticated = !!config.developerToken;
|
|
}
|
|
|
|
loadConfig(_config) {}
|
|
|
|
hasOnceAuthed() {
|
|
throw Error('Unimplemented: [AppleMusic:hasOnceAuthed()]');
|
|
}
|
|
|
|
isAuthed() {
|
|
return this.#store.isAuthenticated;
|
|
}
|
|
|
|
newAuth() {
|
|
throw Error('Unimplemented: [AppleMusic:newAuth()]');
|
|
}
|
|
|
|
canTryLogin() {
|
|
return !!this.#store.core.configuration.developerToken;
|
|
}
|
|
|
|
hasProps() {
|
|
return false;
|
|
}
|
|
|
|
getProps() {
|
|
throw Error('Unimplemented: [AppleMusic:getProps()]');
|
|
}
|
|
|
|
async login() {
|
|
throw Error('Unimplemented: [AppleMusic:login()]');
|
|
}
|
|
|
|
validateType(uri) {
|
|
const {type} = this.identifyType(uri);
|
|
return type in validUriTypes;
|
|
}
|
|
|
|
identifyType(uri) {
|
|
return this.parseURI(uri).type;
|
|
}
|
|
|
|
parseURI(uri, storefront) {
|
|
const match = uri.match(AppleMusic[symbols.meta].VALID_URL);
|
|
if (!match) return null;
|
|
const isURI = !!match[4];
|
|
const parsedURL = xurl.parse(uri, true);
|
|
const collection_type = isURI ? match[4] : match[2] === 'song' ? 'track' : match[2];
|
|
const id = isURI ? match[5] : parsedURL.query.i || path.basename(parsedURL.pathname);
|
|
const type = isURI ? match[4] : collection_type == 'album' && parsedURL.query.i ? 'track' : collection_type;
|
|
const scope = collection_type == 'track' || (collection_type == 'album' && parsedURL.query.i) ? 'song' : collection_type;
|
|
storefront = match[1] || storefront || (#store in this ? this.#store.defaultStorefront : 'us');
|
|
return {
|
|
id,
|
|
type,
|
|
key: match[3] || null,
|
|
uri: `apple_music:${type}:${id}`,
|
|
url: `https://music.apple.com/${storefront}/${scope}/${id}`,
|
|
storefront,
|
|
collection_type,
|
|
};
|
|
}
|
|
|
|
wrapTrackMeta(trackInfo, albumInfo = {}) {
|
|
return {
|
|
id: trackInfo.id,
|
|
uri: `apple_music:track:${albumInfo.id}i${trackInfo.id}`,
|
|
link: trackInfo.attributes.url,
|
|
name: trackInfo.attributes.name,
|
|
artists: [trackInfo.attributes.artistName],
|
|
album: albumInfo.name,
|
|
album_uri: `apple_music:album:${albumInfo.id}`,
|
|
album_type: albumInfo.type,
|
|
images: trackInfo.attributes.artwork,
|
|
duration: trackInfo.attributes.durationInMillis,
|
|
album_artist: albumInfo.artists[0],
|
|
track_number: trackInfo.attributes.trackNumber,
|
|
total_tracks: albumInfo.ntracks,
|
|
release_date: albumInfo.release_date,
|
|
disc_number: trackInfo.attributes.discNumber,
|
|
contentRating: trackInfo.attributes.contentRating,
|
|
isrc: trackInfo.attributes.isrc,
|
|
genres: trackInfo.attributes.genreNames,
|
|
label: albumInfo.label,
|
|
copyrights: albumInfo.copyrights,
|
|
composers: trackInfo.attributes.composerName,
|
|
compilation: albumInfo.type === 'compilation',
|
|
getImage: albumInfo.getImage,
|
|
};
|
|
}
|
|
|
|
wrapAlbumData(albumObject) {
|
|
return {
|
|
id: albumObject.id,
|
|
uri: albumObject.attributes.url,
|
|
name: albumObject.attributes.name.replace(/\s-\s(Single|EP)$/, ''),
|
|
artists: [albumObject.attributes.artistName],
|
|
type:
|
|
albumObject.attributes.artistName === 'Various Artists' && albumObject.relationships.artists.data.length === 0
|
|
? 'compilation'
|
|
: albumObject.attributes.isSingle
|
|
? 'single'
|
|
: 'album',
|
|
genres: albumObject.attributes.genreNames,
|
|
copyrights: [{type: 'P', text: albumObject.attributes.copyright}],
|
|
images: albumObject.attributes.artwork,
|
|
label: albumObject.attributes.recordLabel,
|
|
release_date: (date =>
|
|
typeof date === 'string'
|
|
? date
|
|
: [
|
|
[date.year, 4],
|
|
[date.month, 2],
|
|
[date.day, 2],
|
|
]
|
|
.map(([val, size]) => val.toString().padStart(size, '0'))
|
|
.join('-'))(albumObject.attributes.releaseDate),
|
|
tracks: albumObject.tracks,
|
|
ntracks: albumObject.attributes.trackCount,
|
|
getImage(width, height) {
|
|
const min = (val, max) => Math.min(max, val) || max;
|
|
const images = albumObject.attributes.artwork;
|
|
return images.url.replace('{w}x{h}', `${min(width, images.width)}x${min(height, images.height)}`);
|
|
},
|
|
};
|
|
}
|
|
|
|
wrapArtistData(artistObject) {
|
|
return {
|
|
id: artistObject.id,
|
|
uri: artistObject.attributes.url,
|
|
name: artistObject.attributes.name,
|
|
genres: artistObject.attributes.genreNames,
|
|
albums: artistObject.albums.map(album => album.id),
|
|
nalbums: artistObject.albums.length,
|
|
};
|
|
}
|
|
|
|
wrapPlaylistData(playlistObject) {
|
|
return {
|
|
id: playlistObject.id,
|
|
uri: playlistObject.attributes.url,
|
|
name: playlistObject.attributes.name,
|
|
followers: null,
|
|
description: (playlistObject.attributes.description || {short: null}).short,
|
|
owner_id: null,
|
|
owner_name: playlistObject.attributes.curatorName,
|
|
type: playlistObject.attributes.playlistType.split('-').map(word => `${word[0].toUpperCase()}${word.slice(1)}`),
|
|
tracks: playlistObject.tracks,
|
|
ntracks: playlistObject.tracks.length,
|
|
// hasNonTrack: !!~playlistObject.attributes.trackTypes.findIndex(type => type !== 'songs'),
|
|
};
|
|
}
|
|
|
|
async processData(uris, max, store, coreFn) {
|
|
const wasArr = Array.isArray(uris);
|
|
uris = (wasArr ? uris : [uris]).flatMap(_uri => {
|
|
const parsed = this.parseURI(_uri, store);
|
|
if (!parsed) return [];
|
|
parsed.value = this.#store.cache.get(parsed.uri);
|
|
return [[parsed.id, parsed]];
|
|
});
|
|
const packs = uris.filter(([, {value}]) => !value).map(([, parsed]) => parsed);
|
|
uris = Object.fromEntries(uris);
|
|
if (packs.length)
|
|
(
|
|
await Promise.mapSeries(
|
|
Object.entries(
|
|
// organise by storefront
|
|
packs.reduce(
|
|
(all, item) => (((all[item.storefront] = all[item.storefront] || []), all[item.storefront].push(item)), all),
|
|
{},
|
|
),
|
|
),
|
|
async ([storefront, _items]) =>
|
|
Promise.mapSeries(
|
|
// cut to maximum query length
|
|
((f, c) => (
|
|
(c = Math.min(c, f.length)), [...Array(Math.ceil(f.length / c))].map((_, i) => f.slice(i * c, i * c + c))
|
|
))(_items, max || Infinity),
|
|
async items => coreFn(items, storefront), // request select collection
|
|
),
|
|
)
|
|
)
|
|
.flat(2)
|
|
.forEach(item => (item ? this.#store.cache.set(uris[item.id].uri, (uris[item.id].value = item)) : null));
|
|
const ret = Object.values(uris).map(item => item.value);
|
|
return !wasArr ? ret[0] : ret;
|
|
}
|
|
|
|
async depaginate(paginatedObject, nextHandler) {
|
|
const {data, next} = await paginatedObject;
|
|
if (!next) return data;
|
|
return data.concat(await this.depaginate(await nextHandler(next), nextHandler));
|
|
}
|
|
|
|
async getTrack(uris, store) {
|
|
return this.processData(uris, 300, store, async (items, storefront) => {
|
|
const {data: tracks} = await this.#store.core.songs.get(`?ids=${items.map(item => item.id).join(',')}`, {storefront});
|
|
await this.getAlbum(
|
|
tracks.flatMap(item => item.relationships.albums.data.map(item => `apple_music:album:${item.id}`)),
|
|
storefront,
|
|
);
|
|
return Promise.mapSeries(tracks, async track => {
|
|
track.artists = await this.depaginate(
|
|
track.relationships.artists,
|
|
async nextUrl => await this.#store.core.songs.get(`${track.id}${nextUrl.split(track.href)[1]}`, {storefront}),
|
|
);
|
|
track.albums = await this.depaginate(track.relationships.albums, nextUrl => {
|
|
let err = new Error('Unimplemented: track albums pagination');
|
|
[err.trackId, err.trackHref, err.nextUrl] = [track.id, track.href, nextUrl];
|
|
throw err;
|
|
// this.#store.core.songs.get(`${track.id}${nextUrl.split(track.href)[1]}`, {storefront});
|
|
});
|
|
if (track.albums.length > 1) {
|
|
let err = new Error('Unimplemented: track with multiple albums');
|
|
[err.trackId, err.trackHref] = [track.id, track.href];
|
|
throw err;
|
|
}
|
|
return this.wrapTrackMeta(
|
|
track,
|
|
await this.getAlbum(`apple_music:album:${track.relationships.albums.data[0].id}`, storefront),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
async getAlbum(uris, store) {
|
|
return this.processData(uris, 100, store, async (items, storefront) =>
|
|
Promise.mapSeries(
|
|
(await this.#store.core.albums.get(`?ids=${items.map(item => item.id).join(',')}`, {storefront})).data,
|
|
async album => {
|
|
album.tracks = await this.depaginate(album.relationships.tracks, nextUrl => {
|
|
let err = new Error('Unimplemented: album tracks pagination');
|
|
[err.albumId, err.albumHref, err.nextUrl] = [album.id, album.href, nextUrl];
|
|
throw err;
|
|
// this.#store.core.albums.get(`${album.id}${nextUrl.split(album.href)[1]}`, {storefront});
|
|
});
|
|
return this.wrapAlbumData(album);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
async getAlbumTracks(url, store) {
|
|
return this.getTrack(
|
|
(await this.getAlbum(url, store)).tracks.map(track => track.attributes.url),
|
|
store,
|
|
);
|
|
}
|
|
|
|
async getArtist(uris, store) {
|
|
return this.processData(uris, 25, store, async (items, storefront) =>
|
|
Promise.mapSeries(
|
|
(await this.#store.core.artists.get(`?ids=${items.map(item => item.id).join(',')}`, {storefront})).data,
|
|
async artist => {
|
|
artist.albums = await this.depaginate(artist.relationships.albums, nextUrl =>
|
|
this.#store.core.artists.get(`${artist.id}${nextUrl.split(artist.href)[1]}`, {storefront}),
|
|
);
|
|
return this.wrapArtistData(artist);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
async getPlaylist(uris, store) {
|
|
return this.processData(uris, 25, store, async (items, storefront) =>
|
|
Promise.mapSeries(
|
|
(await this.#store.core.playlists.get(`?ids=${items.map(item => item.id).join(',')}`, {storefront})).data,
|
|
async playlist => {
|
|
playlist.tracks = await this.depaginate(playlist.relationships.tracks, nextUrl =>
|
|
this.#store.core.playlists.get(`${playlist.id}${nextUrl.split(playlist.href)[1]}`, {storefront}),
|
|
);
|
|
return this.wrapPlaylistData(playlist);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
async getPlaylistTracks(uris, store) {
|
|
return this.getTrack(
|
|
(await this.getPlaylist(uris, store)).tracks.map(track => track.attributes.url),
|
|
store,
|
|
);
|
|
}
|
|
|
|
async getArtistAlbums(uris, store) {
|
|
return this.getAlbum(
|
|
(await this.getArtist(uris)).albums.map(album => `apple_music:album:${album}`),
|
|
store,
|
|
);
|
|
}
|
|
}
|