fix(dz): implement alternative track fallback for error 2002

("Track token has
no sufficient rights on requested media").

Previous behavior:
- Only tried format fallback (FLAC → MP3_320 → MP3_128)
- Used same track token for all format attempts
- Failed when error 2002 occurred even if alternative tracks existed

New behavior:
- When error 2002 occurs, fetches FALLBACK.SNG_ID and gets fresh token
- Retries with same format but different track ID
- Loops through all alternative track IDs before trying format fallback
- Only after exhausting alternatives does it fall back to lower quality formats
This commit is contained in:
2025-10-03 11:43:11 -04:00
parent 0ef56c3bed
commit 6fff93fe45
2 changed files with 148 additions and 63 deletions

View File

@@ -438,7 +438,7 @@ export class DeezerAPI {
}
// Get track download URL
async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string, retryCount: number = 0): Promise<string | null> {
async getTrackDownloadUrl(trackToken: string, format: string, licenseToken: string, retryCount: number = 0): Promise<{ url: string | null; errorCode?: number; errorMessage?: string }> {
console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken });
try {
@@ -479,19 +479,19 @@ export class DeezerAPI {
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;
// Return error details so caller can handle alternative track fallback
return { url: null, errorCode: error.code, errorMessage: error.message };
}
if (trackData.media && trackData.media.length > 0) {
const url = trackData.media[0].sources[0].url;
console.log('[DEBUG] Got download URL:', url);
return url;
return { url };
}
}
console.error('[ERROR] No download URL in response:', result);
return null;
return { url: null };
} catch (error: any) {
console.error('[ERROR] Failed to get track download URL:', error);

View File

@@ -68,6 +68,46 @@ export class DeezerQueueManager {
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
*/
@@ -175,9 +215,6 @@ 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;
@@ -186,37 +223,62 @@ export class DeezerQueueManager {
throw new Error('License token not found');
}
// Get download URL using fresh token
let downloadURL = await deezerAPI.getTrackDownloadUrl(
trackData.TRACK_TOKEN,
appSettings.deezerFormat,
licenseToken
);
// Try to get download URL with alternative track fallback (error 2002 handling)
let downloadURL: string | undefined;
let finalTrackId: string | undefined;
let finalFormat: string | undefined;
// 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) {
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
console.log(`[DeezerQueueManager] ${appSettings.deezerFormat} not available for "${track.title}", trying ${format}...`);
downloadURL = await deezerAPI.getTrackDownloadUrl(trackData.TRACK_TOKEN, format, licenseToken);
if (downloadURL) break;
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 {
// 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
);
throw error;
}
}
if (!downloadURL) {
throw new Error('Failed to get download URL from Deezer');
// 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
@@ -228,12 +290,12 @@ export class DeezerQueueManager {
}
});
// Download the track (use trackData.SNG_ID for decryption in case it's a fallback track)
// Download the track (use finalTrackId for decryption - might be original or fallback track)
const filePath = await downloadTrack(
track,
downloadURL,
appSettings.musicFolder,
appSettings.deezerFormat,
finalFormat,
(progress) => {
// Update progress in queue
updateQueueItem(item.id, {
@@ -246,7 +308,7 @@ export class DeezerQueueManager {
});
},
0,
trackData.SNG_ID
finalTrackId
);
console.log(`[DeezerQueueManager] Downloaded: ${filePath}`);
@@ -291,9 +353,6 @@ 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;
@@ -301,43 +360,69 @@ export class DeezerQueueManager {
throw new Error('License token not found');
}
let downloadURL = await deezerAPI.getTrackDownloadUrl(
trackData.TRACK_TOKEN,
appSettings.deezerFormat,
licenseToken
);
// Try to get download URL with alternative track fallback (error 2002 handling)
let downloadURL: string | undefined;
let finalTrackId: string | undefined;
let finalFormat: string | undefined;
// 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) {
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
console.log(`[DeezerQueueManager] ${appSettings.deezerFormat} not available for "${track.title}", trying ${format}...`);
downloadURL = await deezerAPI.getTrackDownloadUrl(trackData.TRACK_TOKEN, format, licenseToken);
if (downloadURL) break;
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 {
// 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
);
throw error;
}
}
if (!downloadURL) {
throw new Error('Failed to get download URL from Deezer');
// 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!,
appSettings.deezerFormat,
finalFormat,
(progress) => {
// Update progress in queue
updateQueueItem(item.id, {
@@ -350,7 +435,7 @@ export class DeezerQueueManager {
});
},
0,
trackData.SNG_ID
finalTrackId
);
results.push(filePath);