mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
235 lines
7.3 KiB
TypeScript
235 lines
7.3 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 { 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<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/');
|
|
|
|
// 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<number[]>('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<boolean> {
|
|
const paths = generateTrackPath(track, musicFolder, format, false);
|
|
const finalPath = `${paths.filepath}/${paths.filename}`;
|
|
|
|
return await exists(finalPath);
|
|
}
|