/** * Deezer track downloader with streaming and decryption */ import { fetch } from '@tauri-apps/plugin-http'; import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs'; import { invoke } from '@tauri-apps/api/core'; import { generateTrackPath } from './paths'; import { tagAudioFile } from './tagger'; import { downloadCover, saveCoverToAlbumFolder } from './imageDownload'; import { settings } from '$lib/stores/settings'; import { get } from 'svelte/store'; import type { DeezerTrack } from '$lib/types/deezer'; export interface DownloadProgress { downloaded: number; total: number; percentage: number; } export type ProgressCallback = (progress: DownloadProgress) => void; /** * Download and decrypt a single track */ export async function downloadTrack( track: DeezerTrack, downloadURL: string, musicFolder: string, format: string, onProgress?: ProgressCallback, retryCount: number = 0, decryptionTrackId?: string ): Promise { // Generate paths const paths = generateTrackPath(track, musicFolder, format, false); // Ensure temp folder exists const tempFolder = `${musicFolder}/_temp`; try { await mkdir(tempFolder, { recursive: true }); } catch (error) { // Folder might already exist } // Ensure target folder exists try { await mkdir(paths.filepath, { recursive: true }); } catch (error) { // Folder might already exist } // Download to temp file console.log('Downloading track:', track.title); console.log('Download URL:', downloadURL); console.log('Temp path:', paths.tempPath); try { // Fetch the track with timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout const response = await fetch(downloadURL, { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36' }, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const totalSize = parseInt(response.headers.get('content-length') || '0'); const isCrypted = downloadURL.includes('/mobile/') || downloadURL.includes('/media/'); // Stream the response with progress tracking const reader = response.body!.getReader(); const chunks: Uint8Array[] = []; let downloadedBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); downloadedBytes += value.length; // Call progress callback if (onProgress && totalSize > 0) { const percentage = (downloadedBytes / totalSize) * 100; console.log(`[Download Progress] ${downloadedBytes}/${totalSize} bytes (${percentage.toFixed(1)}%)`); onProgress({ downloaded: downloadedBytes, total: totalSize, percentage }); } } // Combine chunks into single Uint8Array const encryptedData = new Uint8Array(downloadedBytes); let offset = 0; for (const chunk of chunks) { encryptedData.set(chunk, offset); offset += chunk.length; } console.log(`Downloaded ${encryptedData.length} bytes, encrypted: ${isCrypted}`); // Decrypt if needed let decryptedData: Uint8Array; 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('decrypt_deezer_track', { data: Array.from(encryptedData), trackId: trackIdForDecryption }); decryptedData = new Uint8Array(decrypted); } else { decryptedData = encryptedData; } // Get user settings const appSettings = get(settings); // Download cover art if enabled let coverData: Uint8Array | undefined; if ((appSettings.embedCoverArt || appSettings.saveCoverToFolder) && track.albumCoverUrl) { try { console.log('Downloading cover art...'); coverData = await downloadCover(track.albumCoverUrl); } catch (error) { console.warn('Failed to download cover art:', error); } } // Write untagged file to temp first console.log('Writing untagged file to temp...'); await writeFile(paths.tempPath, decryptedData); // Move to final location const finalPath = `${paths.filepath}/${paths.filename}`; console.log('Moving to final location:', finalPath); // Check if file already exists if (await exists(finalPath)) { console.log('File already exists, removing...'); await remove(finalPath); } await rename(paths.tempPath, finalPath); // Apply tags (works for both MP3 and FLAC) console.log('Tagging audio file...'); await tagAudioFile( finalPath, track, appSettings.embedCoverArt ? coverData : undefined, appSettings.embedLyrics ); console.log('Tagging complete!'); // Save LRC sidecar file if enabled if (appSettings.saveLrcFile && track.lyrics?.sync) { try { const lrcPath = finalPath.replace(/\.[^.]+$/, '.lrc'); console.log('Saving LRC file to:', lrcPath); await writeFile(lrcPath, new TextEncoder().encode(track.lyrics.sync)); } catch (error) { console.warn('Failed to save LRC file:', error); } } // Save cover art to album folder if enabled if (appSettings.saveCoverToFolder && coverData) { try { console.log('Saving cover art to album folder...'); await saveCoverToAlbumFolder(coverData, paths.filepath, 'cover'); } catch (error) { console.warn('Failed to save cover art to folder:', error); } } console.log('Download complete!'); return finalPath; } catch (error: any) { // Clean up temp file on error try { if (await exists(paths.tempPath)) { await remove(paths.tempPath); } } catch (cleanupError) { console.error('Error cleaning up temp file:', cleanupError); } // Retry on network errors or timeout (max 3 retries) const networkErrors = ['ECONNABORTED', 'ECONNREFUSED', 'ECONNRESET', 'ENETRESET', 'ETIMEDOUT']; const isNetworkError = error.code && networkErrors.includes(error.code); const isTimeout = error.name === 'AbortError'; if ((isNetworkError || isTimeout) && retryCount < 3) { 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, decryptionTrackId); } throw error; } } /** * Check if a track file already exists */ export async function trackExists( track: DeezerTrack, musicFolder: string, format: string ): Promise { const paths = generateTrackPath(track, musicFolder, format, false); const finalPath = `${paths.filepath}/${paths.filename}`; return await exists(finalPath); }