Files
shark/src/lib/services/deezer/queueManager.ts

592 lines
20 KiB
TypeScript

/**
* 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<string, Uint8Array> = 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<any> {
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<void> {
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<void> {
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<void> {
// 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<void> {
// 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<void> {
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<void> {
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<void>[] = [];
const downloadNext = async (): Promise<void> => {
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();