mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
592 lines
20 KiB
TypeScript
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();
|