Files
shark/src/lib/services/deezer/downloader.ts

215 lines
6.2 KiB
TypeScript

/**
* 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<string> {
// 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<Uint8Array> {
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<boolean> {
const paths = generateTrackPath(track, musicFolder, format, false);
const finalPath = `${paths.filepath}/${paths.filename}`;
return await exists(finalPath);
}