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);