From 1134680c881b0b6be74cd95b1c400d50483758f8 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 11 Feb 2026 10:28:48 +0000 Subject: [PATCH] (WIP) add qobuz --- index.html | 10 ++ js/app.js | 3 +- js/music-api.js | 172 ++++++++++++++++++++++++++++++++ js/qobuz-api.js | 255 ++++++++++++++++++++++++++++++++++++++++++++++++ js/router.js | 48 ++++++--- js/settings.js | 12 +++ js/storage.js | 16 +++ js/ui.js | 27 ++--- 8 files changed, 514 insertions(+), 29 deletions(-) create mode 100644 js/music-api.js create mode 100644 js/qobuz-api.js diff --git a/index.html b/index.html index 122bfef..096b2b9 100644 --- a/index.html +++ b/index.html @@ -3173,6 +3173,16 @@
+
+
+ Music Provider + Default service for searching and streaming +
+ +
Streaming Quality diff --git a/js/app.js b/js/app.js index b3d58ae..5679124 100644 --- a/js/app.js +++ b/js/app.js @@ -1,5 +1,6 @@ //js/app.js import { LosslessAPI } from './api.js'; +import { MusicAPI } from './music-api.js'; import { apiSettings, themeManager, @@ -275,7 +276,7 @@ document.addEventListener('DOMContentLoaded', async () => { console.log('[App] Neutralino object NOT detected.'); } - const api = new LosslessAPI(apiSettings); + const api = new MusicAPI(apiSettings); const audioPlayer = document.getElementById('audio-player'); diff --git a/js/music-api.js b/js/music-api.js new file mode 100644 index 0000000..aff6e1a --- /dev/null +++ b/js/music-api.js @@ -0,0 +1,172 @@ +// js/music-api.js +// Unified API wrapper that supports both Tidal and Qobuz + +import { LosslessAPI } from './api.js'; +import { QobuzAPI } from './qobuz-api.js'; +import { musicProviderSettings } from './storage.js'; + +export class MusicAPI { + constructor(settings) { + this.tidalAPI = new LosslessAPI(settings); + this.qobuzAPI = new QobuzAPI(); + this._settings = settings; + } + + getCurrentProvider() { + return musicProviderSettings.getProvider(); + } + + // Get the appropriate API based on provider + getAPI(provider = null) { + const p = provider || this.getCurrentProvider(); + return p === 'qobuz' ? this.qobuzAPI : this.tidalAPI; + } + + // Search methods + async searchTracks(query, options = {}) { + const provider = options.provider || this.getCurrentProvider(); + return this.getAPI(provider).searchTracks(query, options); + } + + async searchArtists(query, options = {}) { + const provider = options.provider || this.getCurrentProvider(); + return this.getAPI(provider).searchArtists(query, options); + } + + async searchAlbums(query, options = {}) { + const provider = options.provider || this.getCurrentProvider(); + return this.getAPI(provider).searchAlbums(query, options); + } + + async searchPlaylists(query, options = {}) { + const provider = options.provider || this.getCurrentProvider(); + if (provider === 'qobuz') { + // Qobuz doesn't support playlist search, return empty + return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; + } + return this.tidalAPI.searchPlaylists(query, options); + } + + // Get methods + async getTrack(id, quality, provider = null) { + const p = provider || this.getProviderFromId(id) || this.getCurrentProvider(); + const api = this.getAPI(p); + const cleanId = this.stripProviderPrefix(id); + return api.getTrack(cleanId, quality); + } + + async getTrackMetadata(id, provider = null) { + const p = provider || this.getProviderFromId(id) || this.getCurrentProvider(); + const api = this.getAPI(p); + const cleanId = this.stripProviderPrefix(id); + return api.getTrackMetadata(cleanId); + } + + async getAlbum(id, provider = null) { + const p = provider || this.getProviderFromId(id) || this.getCurrentProvider(); + const api = this.getAPI(p); + const cleanId = this.stripProviderPrefix(id); + return api.getAlbum(cleanId); + } + + async getArtist(id, provider = null) { + const p = provider || this.getProviderFromId(id) || this.getCurrentProvider(); + const api = this.getAPI(p); + const cleanId = this.stripProviderPrefix(id); + return api.getArtist(cleanId); + } + + async getPlaylist(id, provider = null) { + // Playlists are always Tidal for now + return this.tidalAPI.getPlaylist(id); + } + + async getMix(id, provider = null) { + // Mixes are always Tidal for now + return this.tidalAPI.getMix(id); + } + + // Stream methods + async getStreamUrl(id, quality, provider = null) { + const p = provider || this.getProviderFromId(id) || this.getCurrentProvider(); + const api = this.getAPI(p); + const cleanId = this.stripProviderPrefix(id); + return api.getStreamUrl(cleanId, quality); + } + + // Cover/artwork methods + getCoverUrl(id, size = '320') { + if (typeof id === 'string' && id.startsWith('q:')) { + return this.qobuzAPI.getCoverUrl(id.slice(2), size); + } + return this.tidalAPI.getCoverUrl(id, size); + } + + getArtistPictureUrl(id, size = '320') { + if (typeof id === 'string' && id.startsWith('q:')) { + return this.qobuzAPI.getArtistPictureUrl(id.slice(2), size); + } + return this.tidalAPI.getArtistPictureUrl(id, size); + } + + // Helper methods + getProviderFromId(id) { + if (typeof id === 'string') { + if (id.startsWith('q:')) return 'qobuz'; + 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 provider = this.getProviderFromId(id) || this.getCurrentProvider(); + const api = this.getAPI(provider); + const cleanId = this.stripProviderPrefix(id); + return api.downloadTrack(cleanId, quality, filename, options); + } + + // Similar/recommendation methods + async getSimilarArtists(artistId) { + const provider = this.getProviderFromId(artistId) || this.getCurrentProvider(); + const api = this.getAPI(provider); + const cleanId = this.stripProviderPrefix(artistId); + return api.getSimilarArtists(cleanId); + } + + async getSimilarAlbums(albumId) { + const provider = this.getProviderFromId(albumId) || this.getCurrentProvider(); + const api = this.getAPI(provider); + const cleanId = this.stripProviderPrefix(albumId); + return api.getSimilarAlbums(cleanId); + } + + async getRecommendedTracksForPlaylist(tracks, limit = 20) { + // Use Tidal for recommendations + return this.tidalAPI.getRecommendedTracksForPlaylist(tracks, limit); + } + + // Cache methods + async clearCache() { + await this.tidalAPI.clearCache(); + // Qobuz doesn't have cache yet + } + + getCacheStats() { + return this.tidalAPI.getCacheStats(); + } + + // Settings accessor for compatibility + get settings() { + return this._settings; + } +} diff --git a/js/qobuz-api.js b/js/qobuz-api.js new file mode 100644 index 0000000..3efa0a0 --- /dev/null +++ b/js/qobuz-api.js @@ -0,0 +1,255 @@ +// js/qobuz-api.js +// Qobuz API integration for Monochrome Music + +const QOBUZ_API_BASE = 'https://qobuz.squid.wtf/api'; + +export class QobuzAPI { + constructor() { + this.baseUrl = QOBUZ_API_BASE; + } + + async fetchWithRetry(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + + try { + const response = await fetch(url, { signal: options.signal }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Qobuz API request failed:', error); + throw error; + } + } + + // Search tracks + async searchTracks(query, options = {}) { + try { + const data = await this.fetchWithRetry(`/get-music?q=${encodeURIComponent(query)}`); + + if (!data.success || !data.data) { + return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; + } + + // Transform Qobuz tracks to match Tidal format + const tracks = (data.data.tracks?.items || []).map((track) => this.transformTrack(track)); + + return { + items: tracks, + limit: data.data.tracks?.limit || tracks.length, + offset: data.data.tracks?.offset || 0, + totalNumberOfItems: data.data.tracks?.total || tracks.length, + }; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Qobuz track search failed:', error); + return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; + } + } + + // Search albums + async searchAlbums(query, options = {}) { + try { + const data = await this.fetchWithRetry(`/get-music?q=${encodeURIComponent(query)}`); + + if (!data.success || !data.data) { + return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; + } + + // Transform Qobuz albums to match Tidal format + const albums = (data.data.albums?.items || []).map((album) => this.transformAlbum(album)); + + return { + items: albums, + limit: data.data.albums?.limit || albums.length, + offset: data.data.albums?.offset || 0, + totalNumberOfItems: data.data.albums?.total || albums.length, + }; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Qobuz album search failed:', error); + return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; + } + } + + // Search artists + async searchArtists(query, options = {}) { + try { + const data = await this.fetchWithRetry(`/get-music?q=${encodeURIComponent(query)}`); + + if (!data.success || !data.data) { + return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; + } + + // Transform Qobuz artists to match Tidal format + const artists = (data.data.artists?.items || []).map((artist) => this.transformArtist(artist)); + + return { + items: artists, + limit: data.data.artists?.limit || artists.length, + offset: data.data.artists?.offset || 0, + totalNumberOfItems: data.data.artists?.total || artists.length, + }; + } catch (error) { + if (error.name === 'AbortError') throw error; + console.error('Qobuz artist search failed:', error); + return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; + } + } + + // Get track details + async getTrack(id) { + try { + // For Qobuz, we'll need to search or get from album + // Since the API might not have a direct track endpoint + const data = await this.fetchWithRetry(`/get-music?q=${encodeURIComponent(id)}`); + + if (!data.success || !data.data) { + throw new Error('Track not found'); + } + + const track = data.data.tracks?.items?.find((t) => t.id === id || t.isrc === id); + if (!track) { + throw new Error('Track not found'); + } + + return this.transformTrack(track); + } catch (error) { + console.error('Qobuz getTrack failed:', error); + throw error; + } + } + + // Get album details + async getAlbum(id) { + try { + const data = await this.fetchWithRetry(`/get-album?album_id=${encodeURIComponent(id)}`); + + if (!data.success || !data.data) { + throw new Error('Album not found'); + } + + const album = this.transformAlbum(data.data); + const tracks = (data.data.tracks || []).map((track) => this.transformTrack(track, data.data)); + + return { album, tracks }; + } catch (error) { + console.error('Qobuz getAlbum failed:', error); + throw error; + } + } + + // Get artist details + async getArtist(id) { + try { + const data = await this.fetchWithRetry(`/get-artist?artist_id=${encodeURIComponent(id)}`); + + if (!data.success || !data.data) { + throw new Error('Artist not found'); + } + + const artist = this.transformArtist(data.data); + const albums = (data.data.albums || []).map((album) => this.transformAlbum(album)); + + return { ...artist, albums, eps: [], tracks: [] }; + } catch (error) { + console.error('Qobuz getArtist failed:', error); + throw error; + } + } + + // Transform Qobuz track to Tidal-like format + transformTrack(track, albumData = null) { + return { + id: `q:${track.id}`, + title: track.title, + duration: track.duration, + artist: track.artist ? this.transformArtist(track.artist) : null, + artists: track.artists ? track.artists.map((a) => this.transformArtist(a)) : [], + album: albumData ? this.transformAlbum(albumData) : track.album ? this.transformAlbum(track.album) : null, + audioQuality: this.mapQuality(track.streaming_quality), + explicit: track.parental_warning || false, + trackNumber: track.track_number, + volumeNumber: track.media_number || 1, + isrc: track.isrc, + provider: 'qobuz', + originalId: track.id, + }; + } + + // Transform Qobuz album to Tidal-like format + transformAlbum(album) { + return { + id: `q:${album.id}`, + title: album.title, + artist: album.artist ? this.transformArtist(album.artist) : null, + artists: album.artists ? album.artists.map((a) => this.transformArtist(a)) : [], + numberOfTracks: album.tracks_count || 0, + releaseDate: album.release_date_original || album.release_date, + cover: album.image?.large || album.image?.medium || album.image?.small, + explicit: album.parental_warning || false, + type: album.album_type === 'ep' ? 'EP' : album.album_type === 'single' ? 'SINGLE' : 'ALBUM', + provider: 'qobuz', + originalId: album.id, + }; + } + + // Transform Qobuz artist to Tidal-like format + transformArtist(artist) { + return { + id: `q:${artist.id}`, + name: artist.name, + picture: artist.image?.large || artist.image?.medium || artist.image?.small, + provider: 'qobuz', + originalId: artist.id, + }; + } + + // Map Qobuz quality to Tidal quality format + mapQuality(qobuzQuality) { + const qualityMap = { + MP3: 'HIGH', + FLAC: 'LOSSLESS', + HiRes: 'HI_RES_LOSSLESS', + }; + return qualityMap[qobuzQuality] || 'LOSSLESS'; + } + + // Get cover URL + getCoverUrl(coverId, size = '320') { + if (!coverId) { + return `https://picsum.photos/seed/${Math.random()}/${size}`; + } + + // Qobuz cover URLs are usually full URLs + if (typeof coverId === 'string' && coverId.startsWith('http')) { + return coverId; + } + + return coverId; + } + + // Get stream URL for a track + async getStreamUrl(trackId) { + try { + const cleanId = trackId.replace(/^q:/, ''); + const data = await this.fetchWithRetry(`/download-music?track_id=${encodeURIComponent(cleanId)}`); + + if (!data.success || !data.data?.url) { + throw new Error('Stream URL not available'); + } + + return data.data.url; + } catch (error) { + console.error('Qobuz getStreamUrl failed:', error); + throw error; + } + } +} + +export const qobuzAPI = new QobuzAPI(); diff --git a/js/router.js b/js/router.js index b09e8b8..edf5092 100644 --- a/js/router.js +++ b/js/router.js @@ -29,39 +29,57 @@ export function createRouter(ui) { const page = parts[0]; const param = parts.slice(1).join('/'); - // Helper to strip /t/ prefix from params (for Tidal ID format like /album/t/123) - const stripTidalPrefix = (p) => (p.startsWith('t/') ? p.slice(2) : p); + // Helper to extract provider prefix and ID from params + // Supports formats like: /track/t/123 (Tidal), /track/q/123 (Qobuz), /track/123 (default) + const extractProviderAndId = (p) => { + if (p.startsWith('t/')) { + return { provider: 'tidal', id: p.slice(2) }; + } + if (p.startsWith('q/')) { + return { provider: 'qobuz', id: p.slice(2) }; + } + return { provider: null, id: p }; + }; switch (page) { case 'search': await ui.renderSearchPage(decodeURIComponent(param)); break; - case 'album': - await ui.renderAlbumPage(stripTidalPrefix(param)); + case 'album': { + const { provider, id } = extractProviderAndId(param); + await ui.renderAlbumPage(id, provider); break; - case 'artist': - await ui.renderArtistPage(stripTidalPrefix(param)); + } + case 'artist': { + const { provider, id } = extractProviderAndId(param); + await ui.renderArtistPage(id, provider); break; - case 'playlist': - await ui.renderPlaylistPage(stripTidalPrefix(param), 'api'); + } + case 'playlist': { + const { provider, id } = extractProviderAndId(param); + await ui.renderPlaylistPage(id, 'api', provider); break; + } case 'userplaylist': await ui.renderPlaylistPage(param, 'user'); break; case 'folder': await ui.renderFolderPage(param); break; - case 'mix': - await ui.renderMixPage(stripTidalPrefix(param)); + case 'mix': { + const { provider, id } = extractProviderAndId(param); + await ui.renderMixPage(id, provider); break; - case 'track': - const trackParam = stripTidalPrefix(param); - if (trackParam.startsWith('tracker-')) { - await ui.renderTrackerTrackPage(trackParam); + } + case 'track': { + const { provider, id } = extractProviderAndId(param); + if (id.startsWith('tracker-')) { + await ui.renderTrackerTrackPage(id); } else { - await ui.renderTrackPage(trackParam); + await ui.renderTrackPage(id, provider); } break; + } case 'library': await ui.renderLibraryPage(); break; diff --git a/js/settings.js b/js/settings.js index 8af9ce8..cda04e3 100644 --- a/js/settings.js +++ b/js/settings.js @@ -30,6 +30,7 @@ import { settingsUiState, pwaUpdateSettings, contentBlockingSettings, + musicProviderSettings, } from './storage.js'; import { audioContextManager, EQ_PRESETS } from './audio-context.js'; import { getButterchurnPresets } from './visualizers/butterchurn.js'; @@ -696,6 +697,17 @@ export function initializeSettings(scrobbler, player, api, ui) { renderCustomThemeEditor(); }); + // Music Provider setting + const musicProviderSetting = document.getElementById('music-provider-setting'); + if (musicProviderSetting) { + musicProviderSetting.value = musicProviderSettings.getProvider(); + musicProviderSetting.addEventListener('change', (e) => { + musicProviderSettings.setProvider(e.target.value); + // Reload page to apply changes + window.location.reload(); + }); + } + // Streaming Quality setting const streamingQualitySetting = document.getElementById('streaming-quality-setting'); if (streamingQualitySetting) { diff --git a/js/storage.js b/js/storage.js index 632308e..e75368d 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1707,6 +1707,22 @@ export const pwaUpdateSettings = { }, }; +export const musicProviderSettings = { + STORAGE_KEY: 'music-provider', + + getProvider() { + try { + return localStorage.getItem(this.STORAGE_KEY) || 'tidal'; + } catch { + return 'tidal'; + } + }, + + setProvider(provider) { + localStorage.setItem(this.STORAGE_KEY, provider); + }, +}; + export const contentBlockingSettings = { BLOCKED_ARTISTS_KEY: 'blocked-artists', BLOCKED_TRACKS_KEY: 'blocked-tracks', diff --git a/js/ui.js b/js/ui.js index 0b76811..3816449 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1910,11 +1910,12 @@ export class UIRenderer { const signal = this.searchAbortController.signal; try { + const provider = this.api.getCurrentProvider(); const [tracksResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([ - this.api.searchTracks(query, { signal }), - this.api.searchArtists(query, { signal }), - this.api.searchAlbums(query, { signal }), - this.api.searchPlaylists(query, { signal }), + this.api.searchTracks(query, { signal, provider }), + this.api.searchArtists(query, { signal, provider }), + this.api.searchAlbums(query, { signal, provider }), + this.api.searchPlaylists(query, { signal, provider }), ]); let finalTracks = tracksResult.items; @@ -2001,7 +2002,7 @@ export class UIRenderer { } } - async renderAlbumPage(albumId) { + async renderAlbumPage(albumId, provider = null) { this.showPage('album'); const imageEl = document.getElementById('album-detail-image'); @@ -2032,7 +2033,7 @@ export class UIRenderer { `; try { - const { album, tracks } = await this.api.getAlbum(albumId); + const { album, tracks } = await this.api.getAlbum(albumId, provider); const coverUrl = this.api.getCoverUrl(album.cover); imageEl.src = coverUrl; @@ -2329,7 +2330,7 @@ export class UIRenderer { } } - async renderPlaylistPage(playlistId, source = null) { + async renderPlaylistPage(playlistId, source = null, provider = null) { this.showPage('playlist'); // Reset search input for new playlist @@ -2659,7 +2660,7 @@ export class UIRenderer { } } - async renderMixPage(mixId) { + async renderMixPage(mixId, provider = null) { this.showPage('mix'); const imageEl = document.getElementById('mix-detail-image'); @@ -2689,7 +2690,7 @@ export class UIRenderer { `; try { - const { mix, tracks } = await this.api.getMix(mixId); + const { mix, tracks } = await this.api.getMix(mixId, provider); if (mix.cover) { imageEl.src = mix.cover; @@ -2754,7 +2755,7 @@ export class UIRenderer { } } - async renderArtistPage(artistId) { + async renderArtistPage(artistId, provider = null) { this.showPage('artist'); const imageEl = document.getElementById('artist-detail-image'); @@ -2783,7 +2784,7 @@ export class UIRenderer { if (similarSection) similarSection.style.display = 'block'; try { - const artist = await this.api.getArtist(artistId); + const artist = await this.api.getArtist(artistId, provider); // Handle Artist Mix Button const mixBtn = document.getElementById('artist-mix-btn'); @@ -3446,7 +3447,7 @@ export class UIRenderer { ); } - async renderTrackPage(trackId) { + async renderTrackPage(trackId, provider = null) { this.showPage('track'); document.body.classList.add('sidebar-collapsed'); @@ -3489,7 +3490,7 @@ export class UIRenderer { } try { - const track = await this.api.getTrackMetadata(trackId); + const track = await this.api.getTrackMetadata(trackId, provider); const displayTitle = getTrackTitle(track); const artistName = getTrackArtists(track);