mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
// 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 => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user