(WIP) add qobuz

This commit is contained in:
Eduard Prigoana 2026-02-11 10:28:48 +00:00
parent 304eb78d7b
commit 1134680c88
8 changed files with 514 additions and 29 deletions

View file

@ -3173,6 +3173,16 @@
<div class="settings-tab-content" id="settings-tab-audio">
<div class="settings-list">
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Music Provider</span>
<span class="description">Default service for searching and streaming</span>
</div>
<select id="music-provider-setting">
<option value="tidal">Tidal</option>
<option value="qobuz">Qobuz</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Streaming Quality</span>

View file

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

172
js/music-api.js Normal file
View file

@ -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;
}
}

255
js/qobuz-api.js Normal file
View file

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

View file

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

View file

@ -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) {

View file

@ -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',

View file

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