fix(wip): add fallback format support for unavailable tracks

Add support for selecting a fallback audio format if the requested
track format is unavailable. Users can now choose to fall back
to MP3_320, MP3_128, or the highest available format, or opt to fail
if the requested format is not found. The queue manager and downloader
now fetch fresh track data just-in-time, handle dz fallback
parameters, and ensure the correct track ID is used for decryption.
Settings UI and store are updated to allow configuring the fallback format.
This commit is contained in:
2025-10-03 10:28:56 -04:00
parent d74bf7e828
commit 90053b67c5
6 changed files with 220 additions and 33 deletions

View File

@@ -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<any> {
return this.apiCall('deezer.pageTrack', { SNG_ID: trackId });
}
// Get track with fallback (tries pageTrack first, then getData)
async getTrackWithFallback(trackId: string): Promise<any> {
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<any> {
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);

View File

@@ -29,7 +29,8 @@ export async function downloadTrack(
musicFolder: string,
format: string,
onProgress?: ProgressCallback,
retryCount: number = 0
retryCount: number = 0,
decryptionTrackId?: string
): Promise<string> {
// 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<number[]>('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;

View File

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

View File

@@ -23,6 +23,51 @@ export class DeezerQueueManager {
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;
}
/**
* 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();
}
};

View File

@@ -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<void> {
const playlistsFolder = await store.get<string>('playlistsFolder');
const deezerConcurrency = await store.get<number>('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<boolean>('deezerOverwrite');
const embedCoverArt = await store.get<boolean>('embedCoverArt');
const saveCoverToFolder = await store.get<boolean>('saveCoverToFolder');
@@ -55,6 +58,7 @@ export async function loadSettings(): Promise<void> {
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<void> {
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<void> {
await store.set('deezerOverwrite', value);

View File

@@ -6,6 +6,7 @@
setPlaylistsFolder,
setDeezerConcurrency,
setDeezerFormat,
setDeezerFallbackFormat,
setDeezerOverwrite,
setEmbedCoverArt,
setSaveCoverToFolder,
@@ -23,6 +24,7 @@
let currentPlaylistsFolder = $state<string | null>(null);
let currentDeezerConcurrency = $state<number>(1);
let currentDeezerFormat = $state<'FLAC' | 'MP3_320' | 'MP3_128'>('FLAC');
let currentDeezerFallbackFormat = $state<'none' | 'MP3_320' | 'MP3_128' | 'highest'>('none');
let currentDeezerOverwrite = $state<boolean>(false);
let currentEmbedCoverArt = $state<boolean>(true);
let currentSaveCoverToFolder = $state<boolean>(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 @@
<small class="help-text">Select the audio quality for downloaded tracks</small>
</div>
<div class="field-row-stacked">
<label for="deezer-fallback">Fallback Format</label>
<select
id="deezer-fallback"
bind:value={currentDeezerFallbackFormat}
onchange={() => setDeezerFallbackFormat(currentDeezerFallbackFormat)}
>
<option value="none">None (fail if unavailable)</option>
<option value="highest">Highest Available</option>
<option value="MP3_320">MP3 320kbps</option>
<option value="MP3_128">MP3 128kbps</option>
</select>
<small class="help-text">What to do if the requested format is unavailable</small>
</div>
<div class="field-row-stacked">
<label for="deezer-concurrency">Download Concurrency</label>
<div class="slider-container">