/** * 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 { generateBlowfishKey, decryptChunk } from './crypto'; import { generateTrackPath } from './paths'; 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 ): 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/'); // Get the response as array buffer const arrayBuffer = await response.arrayBuffer(); const encryptedData = new Uint8Array(arrayBuffer); console.log(`Downloaded ${encryptedData.length} bytes, encrypted: ${isCrypted}`); // Decrypt if needed let decryptedData: Uint8Array; if (isCrypted) { console.log('Decrypting track...'); decryptedData = await decryptTrackData(encryptedData, track.id.toString()); } else { decryptedData = encryptedData; } // Write to temp file console.log('Writing to temp file...'); 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); 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); } throw error; } } /** * Decrypt track data using Blowfish CBC * Deezer encrypts every 3rd chunk of 2048 bytes */ async function decryptTrackData(data: Uint8Array, trackId: string): Promise { const chunkSize = 2048; const blowfishKey = generateBlowfishKey(trackId); const result: Uint8Array[] = []; let offset = 0; let chunkIndex = 0; // Skip initial padding (null bytes before actual data) while (offset < data.length && data[offset] === 0) { offset++; } // If we found padding, check if next bytes are 'ftyp' (MP4) or 'ID3' (MP3) or 'fLaC' (FLAC) if (offset > 0 && offset + 8 < data.length) { const header = String.fromCharCode(...data.slice(offset + 4, offset + 8)); if (header === 'ftyp') { // Skip the null padding result.push(data.slice(0, offset)); } else { // Reset if we didn't find expected header offset = 0; } } else { offset = 0; } while (offset < data.length) { const remainingBytes = data.length - offset; const currentChunkSize = Math.min(chunkSize, remainingBytes); const chunk = data.slice(offset, offset + currentChunkSize); // Decrypt every 3rd chunk (0, 3, 6, 9, ...) if (chunkIndex % 3 === 0 && chunk.length === chunkSize) { try { const decrypted = decryptChunk(chunk, blowfishKey); result.push(decrypted); } catch (error) { console.error('Error decrypting chunk:', error); result.push(chunk); // Use original if decryption fails } } else { result.push(chunk); } offset += currentChunkSize; chunkIndex++; } // Combine all chunks const totalLength = result.reduce((sum, chunk) => sum + chunk.length, 0); const combined = new Uint8Array(totalLength); let position = 0; for (const chunk of result) { combined.set(chunk, position); position += chunk.length; } return combined; } /** * 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); }