diff --git a/src/lib/services/deezer.ts b/src/lib/services/deezer.ts index e700d7d..8fc0af9 100644 --- a/src/lib/services/deezer.ts +++ b/src/lib/services/deezer.ts @@ -142,6 +142,13 @@ export class DeezerAPI { const errorObj = resultJson.error as any; console.error(`[ERROR] API returned error for ${method}:`, errorStr); + // Handle fallback parameters (Deezer provides alternative parameters to retry with) + if ((resultJson as any).payload?.FALLBACK) { + console.log(`[DEBUG] Using FALLBACK parameters for ${method}:`, (resultJson as any).payload.FALLBACK); + const fallbackArgs = { ...args, ...(resultJson as any).payload.FALLBACK }; + return this.apiCall(method, fallbackArgs, params, retryCount); + } + // Handle rate limiting (error codes 4 and 700) - wait 5 seconds and retry if (errorObj.code && [4, 700].includes(errorObj.code)) { console.log(`[DEBUG] Rate limited (code ${errorObj.code}), waiting 5s before retry...`); @@ -250,6 +257,29 @@ export class DeezerAPI { return this.apiCall('song.getData', { SNG_ID: trackId }); } + // Get track page data (includes more complete track token info) + async getTrackPage(trackId: string): Promise { + return this.apiCall('deezer.pageTrack', { SNG_ID: trackId }); + } + + // Get track with fallback (tries pageTrack first, then getData) + async getTrackWithFallback(trackId: string): Promise { + try { + const pageData = await this.getTrackPage(trackId); + if (pageData && pageData.DATA) { + console.log(`[DEBUG] pageTrack returned for ${trackId}:`, { + FILESIZE_FLAC: pageData.DATA.FILESIZE_FLAC, + FALLBACK: pageData.DATA.FALLBACK, + SNG_ID: pageData.DATA.SNG_ID + }); + return pageData.DATA; + } + } catch (error) { + console.log(`[DEBUG] pageTrack failed for ${trackId}, falling back to getData`); + } + return this.getTrack(trackId); + } + // Search tracks using public API (no authentication required) async searchTracks(query: string, limit: number = 25): Promise { const url = `https://api.deezer.com/search/track?q=${encodeURIComponent(query)}&limit=${limit}`; @@ -412,8 +442,8 @@ export class DeezerAPI { console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken }); try { - // media.deezer.com ONLY needs arl cookie, not sid or other cookies - const cookieHeader = this.arl ? `arl=${this.arl}` : ''; + // Use all cookies, not just arl - sid (session ID) is also required + const cookieHeader = this.getCookieHeader(); console.log('[DEBUG] Sending request to media.deezer.com with cookies:', cookieHeader); const response = await fetch('https://media.deezer.com/v1/get_url', { @@ -444,6 +474,15 @@ export class DeezerAPI { if (result.data && result.data.length > 0) { const trackData = result.data[0]; + + // Check for errors in the response + if (trackData.errors && trackData.errors.length > 0) { + const error = trackData.errors[0]; + console.error('[ERROR] Deezer media API error:', error); + // Return null to trigger fallback, don't throw - let caller handle it + return null; + } + if (trackData.media && trackData.media.length > 0) { const url = trackData.media[0].sources[0].url; console.log('[DEBUG] Got download URL:', url); diff --git a/src/lib/services/deezer/downloader.ts b/src/lib/services/deezer/downloader.ts index 46b6ee7..3bf79c8 100644 --- a/src/lib/services/deezer/downloader.ts +++ b/src/lib/services/deezer/downloader.ts @@ -29,7 +29,8 @@ export async function downloadTrack( musicFolder: string, format: string, onProgress?: ProgressCallback, - retryCount: number = 0 + retryCount: number = 0, + decryptionTrackId?: string ): Promise { // Generate paths const paths = generateTrackPath(track, musicFolder, format, false); @@ -87,10 +88,13 @@ export async function downloadTrack( if (isCrypted) { console.log('Decrypting track using Rust...'); + // Use the provided decryption track ID (for fallback tracks) or the original track ID + const trackIdForDecryption = decryptionTrackId || track.id.toString(); + console.log(`Decrypting with track ID: ${trackIdForDecryption}`); // Call Rust decryption function const decrypted = await invoke('decrypt_deezer_track', { data: Array.from(encryptedData), - trackId: track.id.toString() + trackId: trackIdForDecryption }); decryptedData = new Uint8Array(decrypted); } else { @@ -180,7 +184,7 @@ export async function downloadTrack( const errorType = isTimeout ? 'timeout' : error.code; console.log(`[DEBUG] Download ${errorType}, waiting 2s before retry (${retryCount + 1}/3)...`); await new Promise(resolve => setTimeout(resolve, 2000)); - return downloadTrack(track, downloadURL, musicFolder, format, onProgress, retryCount + 1); + return downloadTrack(track, downloadURL, musicFolder, format, onProgress, retryCount + 1, decryptionTrackId); } throw error; diff --git a/src/lib/services/deezer/playlistDownloader.ts b/src/lib/services/deezer/playlistDownloader.ts index 6823a97..f0f9a3f 100644 --- a/src/lib/services/deezer/playlistDownloader.ts +++ b/src/lib/services/deezer/playlistDownloader.ts @@ -2,7 +2,8 @@ * Download Deezer playlist - adds tracks to queue and creates m3u8 file */ -import { addDeezerTrackToQueue } from './addToQueue'; +import { addToQueue } from '$lib/stores/downloadQueue'; +import { trackExists } from './downloader'; import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8'; import { generateTrackPath } from './paths'; import { settings } from '$lib/stores/settings'; @@ -40,32 +41,40 @@ export async function downloadDeezerPlaylist( } // Add all tracks to download queue - // Note: Tracks from cache don't have md5Origin/mediaVersion/trackToken needed for download - // So we need to call addDeezerTrackToQueue which fetches full data from API - // We add a small delay between requests to avoid rate limiting + // Queue minimal track data - full metadata & tokens will be fetched just-in-time by queueManager + // This avoids token expiration issues and provides instant UI feedback let addedCount = 0; let skippedCount = 0; - for (let i = 0; i < tracks.length; i++) { - const track = tracks[i]; + for (const track of tracks) { try { - const result = await addDeezerTrackToQueue(track.id.toString()); - if (result.added) { - addedCount++; - } else { - skippedCount++; + // Check if track already exists (if overwrite is disabled) + if (!appSettings.deezerOverwrite && appSettings.musicFolder) { + const exists = await trackExists(track, appSettings.musicFolder, appSettings.deezerFormat); + if (exists) { + console.log(`[PlaylistDownloader] Skipping "${track.title}" - already exists`); + skippedCount++; + continue; + } } - // Add delay between requests to avoid rate limiting (except after last track) - if (i < tracks.length - 1) { - await new Promise(resolve => setTimeout(resolve, 300)); - } + // Queue with minimal data - queueManager will fetch full metadata just-in-time + await addToQueue({ + source: 'deezer', + type: 'track', + title: track.title, + artist: track.artist, + totalTracks: 1, + downloadObject: track // Contains id, title, artist, album, etc. from cache + }); + + addedCount++; } catch (error) { - console.error(`[PlaylistDownloader] Error adding track ${track.title}:`, error); + console.error(`[PlaylistDownloader] Error queueing track ${track.title}:`, error); } } - console.log(`[PlaylistDownloader] Added ${addedCount} tracks to queue, skipped ${skippedCount}`); + console.log(`[PlaylistDownloader] Queued ${addedCount} tracks, skipped ${skippedCount}`); // Generate m3u8 file const m3u8Tracks: M3U8Track[] = tracks.map(track => { diff --git a/src/lib/services/deezer/queueManager.ts b/src/lib/services/deezer/queueManager.ts index 9da08f7..268a626 100644 --- a/src/lib/services/deezer/queueManager.ts +++ b/src/lib/services/deezer/queueManager.ts @@ -23,6 +23,51 @@ export class DeezerQueueManager { private abortController: AbortController | null = null; private albumCoverCache: Map = new Map(); + /** + * Fetch fresh track data with valid token right before download + * Uses pageTrack first for complete token data, falls back to getData + * Handles FALLBACK.SNG_ID when requested format is unavailable + */ + private async getValidTrackData(trackId: string, requestedFormat: 'FLAC' | 'MP3_320' | 'MP3_128' = 'FLAC'): Promise { + console.log(`[DeezerQueueManager] Fetching fresh track data for ID: ${trackId}`); + + const trackData = await deezerAPI.getTrackWithFallback(trackId); + + if (!trackData || !trackData.TRACK_TOKEN) { + throw new Error('Failed to get track token'); + } + + // Log important fields for debugging + console.log(`[DeezerQueueManager] Track data:`, { + TRACK_TOKEN: trackData.TRACK_TOKEN ? 'present' : 'missing', + TRACK_TOKEN_EXPIRE: trackData.TRACK_TOKEN_EXPIRE, + FILESIZE_FLAC: trackData.FILESIZE_FLAC, + FILESIZE_MP3_320: trackData.FILESIZE_MP3_320, + FILESIZE_MP3_128: trackData.FILESIZE_MP3_128, + FALLBACK: trackData.FALLBACK, + SNG_ID: trackData.SNG_ID + }); + + // Log token expiration for debugging + if (trackData.TRACK_TOKEN_EXPIRE) { + const expireDate = new Date(trackData.TRACK_TOKEN_EXPIRE * 1000); + console.log(`[DeezerQueueManager] Track token expires at: ${expireDate.toISOString()}`); + } + + // Check if requested format is available + const filesizeField = `FILESIZE_${requestedFormat}`; + const filesize = trackData[filesizeField]; + const isAvailable = filesize && filesize !== "0" && filesize !== 0; + + if (!isAvailable && trackData.FALLBACK?.SNG_ID) { + console.log(`[DeezerQueueManager] ${requestedFormat} not available (FILESIZE=${filesize}), using FALLBACK track ID: ${trackData.FALLBACK.SNG_ID}`); + // Recursively fetch the fallback track + return this.getValidTrackData(trackData.FALLBACK.SNG_ID.toString(), requestedFormat); + } + + return trackData; + } + /** * Start processing the queue */ @@ -130,6 +175,9 @@ export class DeezerQueueManager { } deezerAPI.setArl(authState.arl); + // Fetch fresh track data with valid token just-in-time + const trackData = await this.getValidTrackData(track.id.toString(), appSettings.deezerFormat); + // Get user data for license token const userData = await deezerAPI.getUserData(); const licenseToken = userData.USER?.OPTIONS?.license_token; @@ -138,15 +186,37 @@ export class DeezerQueueManager { throw new Error('License token not found'); } - // Get download URL - const downloadURL = await deezerAPI.getTrackDownloadUrl( - track.trackToken!, + // Get download URL using fresh token + let downloadURL = await deezerAPI.getTrackDownloadUrl( + trackData.TRACK_TOKEN, appSettings.deezerFormat, licenseToken ); + // Apply fallback strategy based on user settings + if (!downloadURL && appSettings.deezerFallbackFormat !== 'none') { + if (appSettings.deezerFallbackFormat === 'highest') { + // Try formats in order: FLAC -> MP3_320 -> MP3_128 + const formats: ('FLAC' | 'MP3_320' | 'MP3_128')[] = ['FLAC', 'MP3_320', 'MP3_128']; + for (const format of formats) { + if (format === appSettings.deezerFormat) continue; // Skip already tried format + console.log(`[DeezerQueueManager] ${appSettings.deezerFormat} not available for "${track.title}", trying ${format}...`); + downloadURL = await deezerAPI.getTrackDownloadUrl(trackData.TRACK_TOKEN, format, licenseToken); + if (downloadURL) break; + } + } else { + // Try specific fallback format + console.log(`[DeezerQueueManager] ${appSettings.deezerFormat} not available for "${track.title}", trying ${appSettings.deezerFallbackFormat}...`); + downloadURL = await deezerAPI.getTrackDownloadUrl( + trackData.TRACK_TOKEN, + appSettings.deezerFallbackFormat, + licenseToken + ); + } + } + if (!downloadURL) { - throw new Error('Failed to get download URL'); + throw new Error('Failed to get download URL from Deezer'); } // Update progress @@ -158,7 +228,7 @@ export class DeezerQueueManager { } }); - // Download the track + // Download the track (use trackData.SNG_ID for decryption in case it's a fallback track) const filePath = await downloadTrack( track, downloadURL, @@ -174,7 +244,9 @@ export class DeezerQueueManager { progress: progress.percentage } }); - } + }, + 0, + trackData.SNG_ID ); console.log(`[DeezerQueueManager] Downloaded: ${filePath}`); @@ -219,6 +291,9 @@ export class DeezerQueueManager { } }); + // Fetch fresh track data with valid token just-in-time + const trackData = await this.getValidTrackData(track.id.toString(), appSettings.deezerFormat); + const userData = await deezerAPI.getUserData(); const licenseToken = userData.USER?.OPTIONS?.license_token; @@ -226,21 +301,46 @@ export class DeezerQueueManager { throw new Error('License token not found'); } - const downloadURL = await deezerAPI.getTrackDownloadUrl( - track.trackToken!, + let downloadURL = await deezerAPI.getTrackDownloadUrl( + trackData.TRACK_TOKEN, appSettings.deezerFormat, licenseToken ); + // Apply fallback strategy based on user settings + if (!downloadURL && appSettings.deezerFallbackFormat !== 'none') { + if (appSettings.deezerFallbackFormat === 'highest') { + // Try formats in order: FLAC -> MP3_320 -> MP3_128 + const formats: ('FLAC' | 'MP3_320' | 'MP3_128')[] = ['FLAC', 'MP3_320', 'MP3_128']; + for (const format of formats) { + if (format === appSettings.deezerFormat) continue; // Skip already tried format + console.log(`[DeezerQueueManager] ${appSettings.deezerFormat} not available for "${track.title}", trying ${format}...`); + downloadURL = await deezerAPI.getTrackDownloadUrl(trackData.TRACK_TOKEN, format, licenseToken); + if (downloadURL) break; + } + } else { + // Try specific fallback format + console.log(`[DeezerQueueManager] ${appSettings.deezerFormat} not available for "${track.title}", trying ${appSettings.deezerFallbackFormat}...`); + downloadURL = await deezerAPI.getTrackDownloadUrl( + trackData.TRACK_TOKEN, + appSettings.deezerFallbackFormat, + licenseToken + ); + } + } + if (!downloadURL) { - throw new Error('Failed to get download URL'); + throw new Error('Failed to get download URL from Deezer'); } const filePath = await downloadTrack( track, downloadURL, appSettings.musicFolder!, - appSettings.deezerFormat + appSettings.deezerFormat, + undefined, + 0, + trackData.SNG_ID ); results.push(filePath); @@ -261,8 +361,9 @@ export class DeezerQueueManager { failedTracks: failedCount }); - // Continue with next track + // Rate limiting: Add delay between downloads to avoid API throttling if (queue.length > 0) { + await new Promise(resolve => setTimeout(resolve, 500)); await downloadNext(); } }; diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index e6a1f35..bb5175b 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -8,6 +8,7 @@ export interface AppSettings { // Deezer download settings deezerConcurrency: number; deezerFormat: 'FLAC' | 'MP3_320' | 'MP3_128'; + deezerFallbackFormat: 'none' | 'MP3_320' | 'MP3_128' | 'highest'; deezerOverwrite: boolean; // Metadata & artwork settings embedCoverArt: boolean; @@ -26,6 +27,7 @@ const defaultSettings: AppSettings = { playlistsFolder: null, deezerConcurrency: 1, deezerFormat: 'FLAC', + deezerFallbackFormat: 'none', deezerOverwrite: false, embedCoverArt: true, saveCoverToFolder: true, @@ -43,6 +45,7 @@ export async function loadSettings(): Promise { const playlistsFolder = await store.get('playlistsFolder'); const deezerConcurrency = await store.get('deezerConcurrency'); const deezerFormat = await store.get<'FLAC' | 'MP3_320' | 'MP3_128'>('deezerFormat'); + const deezerFallbackFormat = await store.get<'none' | 'MP3_320' | 'MP3_128' | 'highest'>('deezerFallbackFormat'); const deezerOverwrite = await store.get('deezerOverwrite'); const embedCoverArt = await store.get('embedCoverArt'); const saveCoverToFolder = await store.get('saveCoverToFolder'); @@ -55,6 +58,7 @@ export async function loadSettings(): Promise { playlistsFolder: playlistsFolder ?? null, deezerConcurrency: deezerConcurrency ?? 1, deezerFormat: deezerFormat ?? 'FLAC', + deezerFallbackFormat: deezerFallbackFormat ?? 'none', deezerOverwrite: deezerOverwrite ?? false, embedCoverArt: embedCoverArt ?? true, saveCoverToFolder: saveCoverToFolder ?? true, @@ -126,6 +130,17 @@ export async function setDeezerFormat(value: 'FLAC' | 'MP3_320' | 'MP3_128'): Pr })); } +// Save Deezer fallback format setting +export async function setDeezerFallbackFormat(value: 'none' | 'MP3_320' | 'MP3_128' | 'highest'): Promise { + await store.set('deezerFallbackFormat', value); + await store.save(); + + settings.update(s => ({ + ...s, + deezerFallbackFormat: value + })); +} + // Save Deezer overwrite setting export async function setDeezerOverwrite(value: boolean): Promise { await store.set('deezerOverwrite', value); diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 5d4cfba..08a332f 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -6,6 +6,7 @@ setPlaylistsFolder, setDeezerConcurrency, setDeezerFormat, + setDeezerFallbackFormat, setDeezerOverwrite, setEmbedCoverArt, setSaveCoverToFolder, @@ -23,6 +24,7 @@ let currentPlaylistsFolder = $state(null); let currentDeezerConcurrency = $state(1); let currentDeezerFormat = $state<'FLAC' | 'MP3_320' | 'MP3_128'>('FLAC'); + let currentDeezerFallbackFormat = $state<'none' | 'MP3_320' | 'MP3_128' | 'highest'>('none'); let currentDeezerOverwrite = $state(false); let currentEmbedCoverArt = $state(true); let currentSaveCoverToFolder = $state(true); @@ -37,6 +39,7 @@ currentPlaylistsFolder = $settings.playlistsFolder; currentDeezerConcurrency = $settings.deezerConcurrency; currentDeezerFormat = $settings.deezerFormat; + currentDeezerFallbackFormat = $settings.deezerFallbackFormat; currentDeezerOverwrite = $settings.deezerOverwrite; currentEmbedCoverArt = $settings.embedCoverArt; currentSaveCoverToFolder = $settings.saveCoverToFolder; @@ -50,6 +53,7 @@ currentPlaylistsFolder = $settings.playlistsFolder; currentDeezerConcurrency = $settings.deezerConcurrency; currentDeezerFormat = $settings.deezerFormat; + currentDeezerFallbackFormat = $settings.deezerFallbackFormat; currentDeezerOverwrite = $settings.deezerOverwrite; currentEmbedCoverArt = $settings.embedCoverArt; currentSaveCoverToFolder = $settings.saveCoverToFolder; @@ -210,6 +214,21 @@ Select the audio quality for downloaded tracks +
+ + + What to do if the requested format is unavailable +
+