391 lines
12 KiB
JavaScript
391 lines
12 KiB
JavaScript
// js/music-api.js
|
|
|
|
import { LosslessAPI } from './api.js';
|
|
import { PodcastsAPI } from './podcasts-api.js';
|
|
import { musicProviderSettings, apiSettings } from './storage.js';
|
|
import { instanceHealth } from './instance-health.js';
|
|
|
|
/**
|
|
* MusicAPI - Singleton class that provides a unified interface for accessing music streaming services.
|
|
*
|
|
* Supports multiple providers (primarily Tidal) and includes functionality for searching,
|
|
* retrieving metadata, streaming, and managing playlists, artists, albums, tracks, and podcasts.
|
|
*
|
|
* @class MusicAPI
|
|
* @classdesc Manages API interactions with music providers and provides caching mechanisms
|
|
* for cover artwork and video metadata.
|
|
*
|
|
* @example
|
|
* // Initialize the MusicAPI
|
|
* await MusicAPI.initialize(settings);
|
|
*
|
|
* // Get the singleton instance
|
|
* const api = MusicAPI.instance;
|
|
*
|
|
* // Search for tracks
|
|
* const results = await api.search('query');
|
|
*
|
|
* // Get a specific track
|
|
* const track = await api.getTrack('track-id');
|
|
*
|
|
* // Get stream URL
|
|
* const streamUrl = await api.getStreamUrl('track-id', 'HIGH');
|
|
*
|
|
* @property {LosslessAPI} tidalAPI - The Tidal API instance
|
|
* @property {PodcastsAPI} podcastsAPI - The Podcasts API instance
|
|
* @property {Object} _settings - Configuration settings
|
|
* @property {Map} videoArtworkCache - Cache for video artwork data
|
|
*
|
|
* @throws {Error} Throws if instance is accessed before initialization
|
|
* @throws {Error} Throws if initialize is called more than once
|
|
*/
|
|
export class MusicAPI {
|
|
static #instance = null;
|
|
/**
|
|
* @type {MusicAPI}
|
|
*/
|
|
static get instance() {
|
|
if (!MusicAPI.#instance) {
|
|
throw new Error('MusicAPI not initialized. Call MusicAPI.initialize(settings) first.');
|
|
}
|
|
return MusicAPI.#instance;
|
|
}
|
|
|
|
/** @private */
|
|
constructor(settings) {
|
|
this.tidalAPI = new LosslessAPI(settings);
|
|
this.podcastsAPI = new PodcastsAPI();
|
|
this._settings = settings;
|
|
this.videoArtworkCache = new Map();
|
|
}
|
|
|
|
static async initialize(settings) {
|
|
if (MusicAPI.#instance) {
|
|
throw new Error('MusicAPI is already initialized');
|
|
}
|
|
|
|
const api = new MusicAPI(settings);
|
|
MusicAPI.#instance = api;
|
|
|
|
if (!window.location?.hostname?.includes('localhost')) {
|
|
instanceHealth.startPeriodicCheck((type) => apiSettings.getInstances(type), 'api');
|
|
instanceHealth.startPeriodicCheck((type) => apiSettings.getInstances(type), 'streaming');
|
|
}
|
|
|
|
return MusicAPI.#instance;
|
|
}
|
|
|
|
getCurrentProvider() {
|
|
return musicProviderSettings.getProvider();
|
|
}
|
|
|
|
// Get the appropriate API based on provider
|
|
getAPI() {
|
|
return this.tidalAPI;
|
|
}
|
|
|
|
// Search methods
|
|
async search(query, options = {}) {
|
|
const api = this.getAPI();
|
|
if (typeof api.search === 'function') {
|
|
return api.search(query, options);
|
|
}
|
|
|
|
// Fallback for providers that don't implement unified search
|
|
const [tracksResult, videosResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
|
|
api.searchTracks(query, options),
|
|
api.searchVideos ? api.searchVideos(query, options) : Promise.resolve({ items: [] }),
|
|
api.searchArtists(query, options),
|
|
api.searchAlbums(query, options),
|
|
api.searchPlaylists ? api.searchPlaylists(query, options) : Promise.resolve({ items: [] }),
|
|
]);
|
|
|
|
return {
|
|
tracks: tracksResult,
|
|
videos: videosResult,
|
|
artists: artistsResult,
|
|
albums: albumsResult,
|
|
playlists: playlistsResult,
|
|
};
|
|
}
|
|
|
|
async searchTracks(query, options = {}) {
|
|
return this.getAPI().searchTracks(query, options);
|
|
}
|
|
|
|
async searchArtists(query, options = {}) {
|
|
return this.getAPI().searchArtists(query, options);
|
|
}
|
|
|
|
async searchAlbums(query, options = {}) {
|
|
return this.getAPI().searchAlbums(query, options);
|
|
}
|
|
|
|
async searchPlaylists(query, options = {}) {
|
|
return this.tidalAPI.searchPlaylists(query, options);
|
|
}
|
|
|
|
async searchVideos(query, options = {}) {
|
|
return this.tidalAPI.searchVideos(query, options);
|
|
}
|
|
|
|
async searchPodcasts(query, options = {}) {
|
|
return this.podcastsAPI.searchPodcasts(query, options);
|
|
}
|
|
|
|
async getPodcast(id, options = {}) {
|
|
return this.podcastsAPI.getPodcastById(id, options);
|
|
}
|
|
|
|
async getPodcastEpisodes(id, options = {}) {
|
|
return this.podcastsAPI.getPodcastEpisodes(id, options);
|
|
}
|
|
|
|
async getTrendingPodcasts(options = {}) {
|
|
return this.podcastsAPI.getTrendingPodcasts(options);
|
|
}
|
|
|
|
// Get methods
|
|
async getTrack(id, quality) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
return api.getTrack(cleanId, quality);
|
|
}
|
|
|
|
async getTrackMetadata(id) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
return api.getTrackMetadata(cleanId);
|
|
}
|
|
|
|
async getAlbum(id) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
return api.getAlbum(cleanId);
|
|
}
|
|
|
|
async getArtist(id) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
return api.getArtist(cleanId);
|
|
}
|
|
|
|
async getArtistBiography(id) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
if (typeof api.getArtistBiography === 'function') {
|
|
return api.getArtistBiography(cleanId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async getVideo(id) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
return api.getVideo(cleanId);
|
|
}
|
|
|
|
async getVideoStreamUrl(id) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
if (typeof api.getVideoStreamUrl === 'function') {
|
|
return api.getVideoStreamUrl(cleanId);
|
|
}
|
|
}
|
|
|
|
async getArtistSocials(artistName) {
|
|
return this.tidalAPI.getArtistSocials(artistName);
|
|
}
|
|
|
|
async getPlaylist(id, _provider = null) {
|
|
// Playlists are always Tidal for now
|
|
return this.tidalAPI.getPlaylist(id);
|
|
}
|
|
|
|
async getMix(id) {
|
|
// Mixes are always Tidal for now
|
|
return this.tidalAPI.getMix(id);
|
|
}
|
|
|
|
async getTrackRecommendations(id) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
if (typeof api.getTrackRecommendations === 'function') {
|
|
return api.getTrackRecommendations(cleanId);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// Stream methods
|
|
async getStreamUrl(id, quality) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
return api.getStreamUrl(cleanId, quality);
|
|
}
|
|
|
|
// Cover/artwork methods
|
|
getCoverUrl(id, size = '320') {
|
|
if (typeof id === 'string' && id.startsWith('blob:')) {
|
|
return id;
|
|
}
|
|
return this.tidalAPI.getCoverUrl(this.stripProviderPrefix(id), size);
|
|
}
|
|
|
|
getCoverSrcset(id) {
|
|
if (typeof id === 'string' && id.startsWith('blob:')) {
|
|
return '';
|
|
}
|
|
return this.tidalAPI.getCoverSrcset(this.stripProviderPrefix(id));
|
|
}
|
|
|
|
getVideoCoverUrl(imageId, size = '1280') {
|
|
if (!imageId) {
|
|
return null;
|
|
}
|
|
if (typeof imageId === 'string' && imageId.startsWith('blob:')) {
|
|
return imageId;
|
|
}
|
|
return this.tidalAPI.getVideoCoverUrl(this.stripProviderPrefix(imageId), size);
|
|
}
|
|
|
|
async getVideoArtwork(title, artist) {
|
|
const cacheKey = `${title}-${artist}`.toLowerCase();
|
|
if (this.videoArtworkCache.has(cacheKey)) {
|
|
return this.videoArtworkCache.get(cacheKey);
|
|
}
|
|
// artwork.boidu.dev developer asked us to disable his API for the time being due to rate limits.
|
|
/*
|
|
try {
|
|
const url = `https://artwork.boidu.dev/?s=${encodeURIComponent(title)}&a=${encodeURIComponent(artist)}`;
|
|
const response = await fetch(url);
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
const result = {
|
|
videoUrl: data.videoUrl || null,
|
|
hlsUrl: data.animated || null,
|
|
};
|
|
this.videoArtworkCache.set(cacheKey, result);
|
|
return result;
|
|
|
|
} catch (error) {
|
|
console.warn('Failed to fetch video artwork:', error);
|
|
return null;
|
|
}
|
|
*/
|
|
}
|
|
|
|
getArtistPictureUrl(id, size = '320') {
|
|
return this.tidalAPI.getArtistPictureUrl(this.stripProviderPrefix(id), size);
|
|
}
|
|
|
|
getArtistPictureSrcset(id) {
|
|
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id));
|
|
}
|
|
|
|
async getArtistBanner(artistName) {
|
|
const cacheKey = `banner-${artistName}`.toLowerCase();
|
|
if (this.videoArtworkCache.has(cacheKey)) {
|
|
return this.videoArtworkCache.get(cacheKey);
|
|
}
|
|
|
|
try {
|
|
const url = `https://artwork-boidu-dev.samidy.workers.dev/artist?a=${encodeURIComponent(artistName)}`;
|
|
const response = await fetch(url);
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
|
|
let hlsUrl = null;
|
|
if (data.animated) {
|
|
if (typeof data.animated === 'string') {
|
|
hlsUrl = data.animated;
|
|
} else if (typeof data.animated === 'object') {
|
|
hlsUrl = data.animated.hls || data.animated.url || data.animated.hlsUrl || data.animated.videoUrl;
|
|
|
|
if (!hlsUrl) {
|
|
for (const key in data.animated) {
|
|
if (typeof data.animated[key] === 'string' && data.animated[key].includes('.m3u8')) {
|
|
hlsUrl = data.animated[key];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
hlsUrl: hlsUrl,
|
|
};
|
|
this.videoArtworkCache.set(cacheKey, result);
|
|
return result;
|
|
} catch (error) {
|
|
console.warn('Failed to fetch artist banner:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
extractStreamUrlFromManifest(manifest) {
|
|
return this.tidalAPI.extractStreamUrlFromManifest(manifest);
|
|
}
|
|
|
|
// Helper methods
|
|
getProviderFromId(id) {
|
|
if (typeof id === 'string') {
|
|
if (id.startsWith('t:')) return 'tidal';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
stripProviderPrefix(id) {
|
|
if (typeof id === 'string') {
|
|
if (id.startsWith('q:') || id.startsWith('t:')) {
|
|
return id.slice(2);
|
|
}
|
|
}
|
|
return id;
|
|
}
|
|
|
|
// Download methods
|
|
async downloadTrack(id, quality, filename, options = {}) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(id);
|
|
return api.downloadTrack(cleanId, quality, filename, options);
|
|
}
|
|
|
|
// Similar/recommendation methods
|
|
async getSimilarArtists(artistId) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(artistId);
|
|
return api.getSimilarArtists(cleanId);
|
|
}
|
|
|
|
async getArtistTopTracks(artistId, options = {}) {
|
|
return this.tidalAPI.getArtistTopTracks(artistId, options);
|
|
}
|
|
|
|
async getSimilarAlbums(albumId) {
|
|
const api = this.getAPI();
|
|
const cleanId = this.stripProviderPrefix(albumId);
|
|
return api.getSimilarAlbums(cleanId);
|
|
}
|
|
|
|
async getRecommendedTracksForPlaylist(tracks, limit = 20, options = {}) {
|
|
// Use Tidal for recommendations
|
|
return this.tidalAPI.getRecommendedTracksForPlaylist(tracks, limit, options);
|
|
}
|
|
|
|
// Cache methods
|
|
async clearCache() {
|
|
await this.tidalAPI.clearCache();
|
|
}
|
|
|
|
getCacheStats() {
|
|
return this.tidalAPI.getCacheStats();
|
|
}
|
|
|
|
// Settings accessor for compatibility
|
|
get settings() {
|
|
return this._settings;
|
|
}
|
|
}
|
|
|
|
export const musicAPI = new MusicAPI();
|