/** * Deezer Queue Manager * Handles download queue processing with configurable concurrency */ import { downloadTrack } from './downloader'; import { deezerAPI } from '../deezer'; import { downloadQueue, updateQueueItem, setCurrentJob, getNextQueuedItem, type QueueItem } from '$lib/stores/downloadQueue'; import { settings } from '$lib/stores/settings'; import { deezerAuth } from '$lib/stores/deezer'; import { syncTrackPaths } from '$lib/library/incrementalSync'; import { get } from 'svelte/store'; import type { DeezerTrack } from '$lib/types/deezer'; export class DeezerQueueManager { private isProcessing = false; 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; } /** * Get download URL with alternative track fallback (Deemix-compliant) * Tries alternative track IDs (FALLBACK.SNG_ID) with same format before trying different formats * Matches Deemix's getPreferredBitrate logic for handling error 2002 */ private async getDownloadUrlWithAlternatives( trackId: string, requestedFormat: 'FLAC' | 'MP3_320' | 'MP3_128', licenseToken: string ): Promise<{ url: string; finalTrackId: string; finalFormat: string }> { console.log(`[DeezerQueueManager] Getting download URL with alternatives for track ${trackId}, format ${requestedFormat}`); // Fetch track data (handles filesize=0 fallback) let trackData = await this.getValidTrackData(trackId, requestedFormat); // Try to get download URL with current track token let result = await deezerAPI.getTrackDownloadUrl(trackData.TRACK_TOKEN, requestedFormat, licenseToken); // If error 2002 (insufficient rights) and has alternative track, try fallback track(s) while (!result.url && result.errorCode === 2002 && trackData.FALLBACK?.SNG_ID) { const fallbackId = trackData.FALLBACK.SNG_ID.toString(); console.log(`[DeezerQueueManager] Error 2002 for track ${trackData.SNG_ID}, trying alternative track ID: ${fallbackId}`); // Fetch fallback track data (with fresh token) trackData = await this.getValidTrackData(fallbackId, requestedFormat); // Try to get download URL with fallback track token (same format) result = await deezerAPI.getTrackDownloadUrl(trackData.TRACK_TOKEN, requestedFormat, licenseToken); } // If we got a URL, return it if (result.url) { return { url: result.url, finalTrackId: trackData.SNG_ID, finalFormat: requestedFormat }; } // If no URL after exhausting alternatives, throw error const errorMsg = result.errorMessage || 'Failed to get download URL'; throw new Error(`${errorMsg} (code: ${result.errorCode || 'unknown'})`); } /** * Start processing the queue */ async start(): Promise { if (this.isProcessing) { console.log('[DeezerQueueManager] Already processing queue'); return; } this.isProcessing = true; this.abortController = new AbortController(); console.log('[DeezerQueueManager] Starting queue processor'); // Clear any stale currentJob from previous session await setCurrentJob(null); try { await this.processQueue(); } catch (error) { console.error('[DeezerQueueManager] Queue processing error:', error); } finally { this.isProcessing = false; this.abortController = null; } } /** * Stop processing the queue */ stop(): void { console.log('[DeezerQueueManager] Stopping queue processor'); this.isProcessing = false; if (this.abortController) { this.abortController.abort(); this.abortController = null; } } /** * Main queue processing loop * Runs continuously while the app is open, waiting for new items */ private async processQueue(): Promise { console.log('[DeezerQueueManager] Queue processor started'); while (this.isProcessing) { const queueState = get(downloadQueue); const nextItem = getNextQueuedItem(queueState); if (!nextItem) { // No items to process - wait and check again await new Promise(resolve => setTimeout(resolve, 500)); continue; } console.log(`[DeezerQueueManager] Processing item: ${nextItem.title}`); // Set as current job await setCurrentJob(nextItem.id); await updateQueueItem(nextItem.id, { status: 'downloading' }); try { // Process based on type if (nextItem.type === 'track') { await this.downloadSingleTrack(nextItem); } else if (nextItem.type === 'album' || nextItem.type === 'playlist') { await this.downloadCollection(nextItem); } // Mark as completed await updateQueueItem(nextItem.id, { status: 'completed', progress: 100 }); } catch (error) { console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error); await updateQueueItem(nextItem.id, { status: 'failed', error: error instanceof Error ? error.message : 'Unknown error' }); } // Clear current job await setCurrentJob(null); } console.log('[DeezerQueueManager] Queue processor stopped'); } /** * Ensure track has cover art URL by fetching album data if needed * Reuses the same logic as addToQueue for consistency */ private async ensureCoverUrl(track: DeezerTrack): Promise { // Skip if already has cover URL if (track.albumCoverUrl) { return; } // Skip if no album ID to fetch with if (!track.albumId || track.albumId === 0) { console.log(`[DeezerQueueManager] Track "${track.title}" has no albumId, fetching track data...`); // Fetch track data to get album ID try { const trackData = await deezerAPI.getTrack(track.id.toString()); if (trackData && trackData.ALB_ID) { track.albumId = parseInt(trackData.ALB_ID.toString(), 10); } else { console.warn(`[DeezerQueueManager] Could not get album ID for track "${track.title}"`); return; } } catch (error) { console.warn(`[DeezerQueueManager] Error fetching track data for "${track.title}":`, error); return; } } // Fetch album data for cover art URL try { const albumData = await deezerAPI.getAlbumData(track.albumId.toString()); if (albumData?.ALB_PICTURE) { track.albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`; track.albumCoverXlUrl = `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg`; console.log(`[DeezerQueueManager] Fetched cover URL for "${track.title}"`); } } catch (error) { console.warn(`[DeezerQueueManager] Could not fetch album data for track "${track.title}":`, error); } } /** * Ensure track has lyrics by fetching if needed * Reuses the same logic as addToQueue for consistency */ private async ensureLyrics(track: DeezerTrack): Promise { // Skip if already has lyrics if (track.lyrics) { return; } // Fetch lyrics from Deezer try { const lyricsData = await deezerAPI.getLyrics(track.id.toString()); if (lyricsData) { // Parse LRC format (synced lyrics) let syncLrc = ''; if (lyricsData.LYRICS_SYNC_JSON) { for (const line of lyricsData.LYRICS_SYNC_JSON) { const text = line.line || ''; const timestamp = line.lrc_timestamp || '[00:00.00]'; syncLrc += `${timestamp}${text}\n`; } } track.lyrics = { sync: syncLrc || undefined, unsync: lyricsData.LYRICS_TEXT || undefined, syncID3: undefined }; console.log(`[DeezerQueueManager] Fetched lyrics for "${track.title}"`); } } catch (error) { console.warn(`[DeezerQueueManager] Could not fetch lyrics for track "${track.title}":`, error); } } /** * Download a single track */ private async downloadSingleTrack(item: QueueItem): Promise { const track = item.downloadObject as DeezerTrack; const appSettings = get(settings); if (!appSettings.musicFolder) { throw new Error('Music folder not configured'); } // Set ARL for authentication const authState = get(deezerAuth); if (!authState.arl) { throw new Error('Deezer ARL not found - please log in'); } deezerAPI.setArl(authState.arl); // Ensure track has cover URL if cover art is enabled if (appSettings.embedCoverArt || appSettings.saveCoverToFolder) { await this.ensureCoverUrl(track); } // Ensure track has lyrics if lyrics are enabled if (appSettings.embedLyrics || appSettings.saveLrcFile) { await this.ensureLyrics(track); } // Get user data for license token const userData = await deezerAPI.getUserData(); const licenseToken = userData.USER?.OPTIONS?.license_token; if (!licenseToken) { throw new Error('License token not found'); } // Try to get download URL with alternative track fallback (error 2002 handling) let downloadURL: string | undefined; let finalTrackId: string | undefined; let finalFormat: string | undefined; try { const result = await this.getDownloadUrlWithAlternatives( track.id.toString(), appSettings.deezerFormat, licenseToken ); downloadURL = result.url; finalTrackId = result.finalTrackId; finalFormat = result.finalFormat; } catch (error) { // If alternative track fallback failed and user wants format fallback, try different formats if (appSettings.deezerFallbackFormat !== 'none') { console.log(`[DeezerQueueManager] Alternative track fallback failed for "${track.title}", trying format fallback...`); const formatsToTry: ('FLAC' | 'MP3_320' | 'MP3_128')[] = appSettings.deezerFallbackFormat === 'highest' ? ['FLAC', 'MP3_320', 'MP3_128'] : [appSettings.deezerFallbackFormat]; let succeeded = false; for (const format of formatsToTry) { if (format === appSettings.deezerFormat) continue; // Skip already tried format try { console.log(`[DeezerQueueManager] Trying format ${format} for "${track.title}"...`); const result = await this.getDownloadUrlWithAlternatives( track.id.toString(), format, licenseToken ); downloadURL = result.url; finalTrackId = result.finalTrackId; finalFormat = result.finalFormat; succeeded = true; break; } catch (formatError) { console.log(`[DeezerQueueManager] Format ${format} also failed for "${track.title}"`); } } if (!succeeded) { throw new Error('Failed to get download URL from Deezer - all alternatives exhausted'); } } else { throw error; } } // These should be defined if we get here without throwing if (!downloadURL || !finalTrackId || !finalFormat) { throw new Error('Failed to get download URL - unexpected state'); } // Update progress await updateQueueItem(item.id, { currentTrack: { title: track.title, artist: track.artist, progress: 0 } }); // Download the track (use finalTrackId for decryption - might be original or fallback track) const filePath = await downloadTrack( track, downloadURL, appSettings.musicFolder, finalFormat, (progress) => { // Update progress in queue updateQueueItem(item.id, { progress: progress.percentage, currentTrack: { title: track.title, artist: track.artist, progress: progress.percentage } }); }, 0, finalTrackId ); console.log(`[DeezerQueueManager] Downloaded: ${filePath}`); // Trigger incremental library sync for this track try { await syncTrackPaths([filePath]); } catch (error) { console.error('[DeezerQueueManager] Error syncing track to library:', error); // Non-fatal - track is downloaded, just not in database yet } } /** * Download a collection (album/playlist) with concurrency control */ private async downloadCollection(item: QueueItem): Promise { const tracks = item.downloadObject as DeezerTrack[]; const appSettings = get(settings); const concurrency = appSettings.deezerConcurrency; console.log(`[DeezerQueueManager] Downloading collection with concurrency: ${concurrency}`); const results: (string | Error)[] = []; let completedCount = 0; let failedCount = 0; // Simple concurrent queue implementation const queue = [...tracks]; const running: Promise[] = []; const downloadNext = async (): Promise => { const track = queue.shift(); if (!track) return; try { await updateQueueItem(item.id, { currentTrack: { title: track.title, artist: track.artist, progress: 0 } }); // Ensure track has cover URL if cover art is enabled if (appSettings.embedCoverArt || appSettings.saveCoverToFolder) { await this.ensureCoverUrl(track); } // Ensure track has lyrics if lyrics are enabled if (appSettings.embedLyrics || appSettings.saveLrcFile) { await this.ensureLyrics(track); } const userData = await deezerAPI.getUserData(); const licenseToken = userData.USER?.OPTIONS?.license_token; if (!licenseToken) { throw new Error('License token not found'); } // Try to get download URL with alternative track fallback (error 2002 handling) let downloadURL: string | undefined; let finalTrackId: string | undefined; let finalFormat: string | undefined; try { const result = await this.getDownloadUrlWithAlternatives( track.id.toString(), appSettings.deezerFormat, licenseToken ); downloadURL = result.url; finalTrackId = result.finalTrackId; finalFormat = result.finalFormat; } catch (error) { // If alternative track fallback failed and user wants format fallback, try different formats if (appSettings.deezerFallbackFormat !== 'none') { console.log(`[DeezerQueueManager] Alternative track fallback failed for "${track.title}", trying format fallback...`); const formatsToTry: ('FLAC' | 'MP3_320' | 'MP3_128')[] = appSettings.deezerFallbackFormat === 'highest' ? ['FLAC', 'MP3_320', 'MP3_128'] : [appSettings.deezerFallbackFormat]; let succeeded = false; for (const format of formatsToTry) { if (format === appSettings.deezerFormat) continue; // Skip already tried format try { console.log(`[DeezerQueueManager] Trying format ${format} for "${track.title}"...`); const result = await this.getDownloadUrlWithAlternatives( track.id.toString(), format, licenseToken ); downloadURL = result.url; finalTrackId = result.finalTrackId; finalFormat = result.finalFormat; succeeded = true; break; } catch (formatError) { console.log(`[DeezerQueueManager] Format ${format} also failed for "${track.title}"`); } } if (!succeeded) { throw new Error('Failed to get download URL from Deezer - all alternatives exhausted'); } } else { throw error; } } // These should be defined if we get here without throwing if (!downloadURL || !finalTrackId || !finalFormat) { throw new Error('Failed to get download URL - unexpected state'); } const filePath = await downloadTrack( track, downloadURL, appSettings.musicFolder!, finalFormat, (progress) => { // Update progress in queue updateQueueItem(item.id, { progress: progress.percentage, currentTrack: { title: track.title, artist: track.artist, progress: progress.percentage } }); }, 0, finalTrackId ); results.push(filePath); completedCount++; } catch (error) { console.error(`[DeezerQueueManager] Error downloading track ${track.title}:`, error); results.push(error as Error); failedCount++; } // Update progress const totalTracks = item.totalTracks; const progress = ((completedCount + failedCount) / totalTracks) * 100; await updateQueueItem(item.id, { progress, completedTracks: completedCount, failedTracks: failedCount }); // Rate limiting: Add delay between downloads to avoid API throttling if (queue.length > 0) { await new Promise(resolve => setTimeout(resolve, 500)); await downloadNext(); } }; // Start initial batch of concurrent downloads for (let i = 0; i < Math.min(concurrency, tracks.length); i++) { running.push(downloadNext()); } // Wait for all downloads to complete await Promise.all(running); console.log(`[DeezerQueueManager] Collection complete: ${completedCount} succeeded, ${failedCount} failed`); // Trigger incremental library sync for all successfully downloaded tracks if (completedCount > 0) { try { const successfulPaths = results.filter(r => typeof r === 'string') as string[]; await syncTrackPaths(successfulPaths); console.log(`[DeezerQueueManager] Synced ${successfulPaths.length} tracks to library`); } catch (error) { console.error('[DeezerQueueManager] Error syncing collection to library:', error); // Non-fatal - tracks are downloaded, just not in database yet } } } } // Singleton instance export const deezerQueueManager = new DeezerQueueManager();