From 96a01bdced99cbba6144c65f73f9f9875040f30d Mon Sep 17 00:00:00 2001 From: Markury Date: Sat, 4 Oct 2025 20:53:59 -0400 Subject: [PATCH] refactor: move download/decryption to backend to fix UI freezing Now implements streaming download+decryption entirely in Rust: - Added reqwest/tokio/futures-util dependencies - Created StreamingDecryptor for chunk-by-chunk decryption - New download_and_decrypt_track command streams to disk directly - Frontend simplified to single invoke() call --- src-tauri/Cargo.lock | 146 +++++++++++++++++++++++++- src-tauri/Cargo.toml | 3 + src-tauri/src/deezer_crypto.rs | 63 +++++++++++ src-tauri/src/lib.rs | 88 +++++++++++++++- src/lib/services/deezer/downloader.ts | 93 +++------------- 5 files changed, 307 insertions(+), 86 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 17d0105..ae5cae8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,9 +8,11 @@ version = "0.1.0" dependencies = [ "blowfish", "byteorder", + "futures-util", "id3", "md5", "metaflac", + "reqwest", "serde", "serde_json", "tauri", @@ -22,6 +24,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-sql", "tauri-plugin-store", + "tokio", ] [[package]] @@ -659,7 +662,7 @@ dependencies = [ "bitflags 2.9.4", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1192,6 +1195,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1199,7 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1213,6 +1225,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1844,6 +1862,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.17" @@ -2464,6 +2498,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2858,6 +2909,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3579,10 +3674,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3593,6 +3690,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3755,6 +3853,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3818,6 +3925,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4116,7 +4246,7 @@ dependencies = [ "bytemuck", "cfg_aliases", "core-graphics", - "foreign-types", + "foreign-types 0.5.0", "js-sys", "log", "objc2 0.5.2", @@ -5085,6 +5215,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6e2d857..c6a8005 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,4 +33,7 @@ tauri-plugin-process = "2" blowfish = "0.9" md5 = "0.7" byteorder = "1.5.0" +reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] } +tokio = { version = "1.47.1", features = ["fs", "io-util"] } +futures-util = "0.3.31" diff --git a/src-tauri/src/deezer_crypto.rs b/src-tauri/src/deezer_crypto.rs index 8663c39..5f883ca 100644 --- a/src-tauri/src/deezer_crypto.rs +++ b/src-tauri/src/deezer_crypto.rs @@ -97,6 +97,69 @@ pub fn decrypt_track(data: &[u8], track_id: &str) -> Vec { result } +/// Streaming decryption state for processing data chunk-by-chunk +pub struct StreamingDecryptor { + blowfish_key: Vec, + buffer: Vec, +} + +impl StreamingDecryptor { + const CHUNK_SIZE: usize = 2048; + const WINDOW_SIZE: usize = Self::CHUNK_SIZE * 3; // 6144 + + pub fn new(track_id: &str) -> Self { + Self { + blowfish_key: generate_blowfish_key(track_id), + buffer: Vec::new(), + } + } + + /// Process incoming data and return decrypted output + /// May return less data than input as it buffers to maintain 6144-byte windows + pub fn process(&mut self, data: &[u8]) -> Vec { + self.buffer.extend_from_slice(data); + let mut output = Vec::new(); + + // Process complete windows + while self.buffer.len() >= Self::WINDOW_SIZE { + let encrypted_chunk = &self.buffer[0..Self::CHUNK_SIZE]; + let plain_part = &self.buffer[Self::CHUNK_SIZE..Self::WINDOW_SIZE]; + + let decrypted = decrypt_chunk(encrypted_chunk, &self.blowfish_key); + output.extend_from_slice(&decrypted); + output.extend_from_slice(plain_part); + + self.buffer.drain(0..Self::WINDOW_SIZE); + } + + output + } + + /// Finalize decryption and return any remaining buffered data + pub fn finalize(self) -> Vec { + if self.buffer.is_empty() { + return Vec::new(); + } + + let remaining = self.buffer.len(); + + if remaining >= Self::CHUNK_SIZE { + // Partial window: decrypt first 2048 bytes, keep rest as-is + let encrypted_chunk = &self.buffer[0..Self::CHUNK_SIZE]; + let plain_part = &self.buffer[Self::CHUNK_SIZE..]; + + let decrypted = decrypt_chunk(encrypted_chunk, &self.blowfish_key); + let mut output = Vec::with_capacity(remaining); + output.extend_from_slice(&decrypted); + output.extend_from_slice(plain_part); + output + } else { + // Less than 2048 bytes: keep as-is + self.buffer + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c1e34c..8aefeee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,7 +36,7 @@ fn read_audio_metadata(path: String) -> Result metadata::read_audio_metadata(&path) } -/// Decrypt Deezer track data +/// Decrypt Deezer track data (legacy - kept for backwards compatibility) #[tauri::command] async fn decrypt_deezer_track(data: Vec, track_id: String) -> Result, String> { // Run decryption on a background thread to avoid blocking the UI @@ -49,6 +49,89 @@ async fn decrypt_deezer_track(data: Vec, track_id: String) -> Result Ok(result) } +/// Download and decrypt a Deezer track, streaming directly to disk +#[tauri::command] +async fn download_and_decrypt_track( + url: String, + track_id: String, + output_path: String, + is_encrypted: bool, +) -> Result<(), String> { + use tokio::io::AsyncWriteExt; + use tokio::fs::File; + use deezer_crypto::StreamingDecryptor; + + // Build HTTP client + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + // Start download + let response = client + .get(&url) + .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36") + .send() + .await + .map_err(|e| format!("Download failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("HTTP error: {}", response.status())); + } + + // Open output file + let mut file = File::create(&output_path) + .await + .map_err(|e| format!("Failed to create output file: {}", e))?; + + // Stream download with optional decryption + if is_encrypted { + let mut decryptor = StreamingDecryptor::new(&track_id); + let mut stream = response.bytes_stream(); + + use futures_util::StreamExt; + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| format!("Download stream error: {}", e))?; + + // Decrypt chunk and write to file + let decrypted = decryptor.process(&chunk); + if !decrypted.is_empty() { + file.write_all(&decrypted) + .await + .map_err(|e| format!("Failed to write to file: {}", e))?; + } + } + + // Write any remaining buffered data + let final_data = decryptor.finalize(); + if !final_data.is_empty() { + file.write_all(&final_data) + .await + .map_err(|e| format!("Failed to write final data: {}", e))?; + } + } else { + // No encryption - just stream directly + let mut stream = response.bytes_stream(); + + use futures_util::StreamExt; + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| format!("Download stream error: {}", e))?; + file.write_all(&chunk) + .await + .map_err(|e| format!("Failed to write to file: {}", e))?; + } + } + + // Ensure all data is flushed + file.flush() + .await + .map_err(|e| format!("Failed to flush file: {}", e))?; + + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let library_migrations = vec![Migration { @@ -171,7 +254,8 @@ pub fn run() { greet, tag_audio_file, read_audio_metadata, - decrypt_deezer_track + decrypt_deezer_track, + download_and_decrypt_track ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/services/deezer/downloader.ts b/src/lib/services/deezer/downloader.ts index ee46fd4..762bd5f 100644 --- a/src/lib/services/deezer/downloader.ts +++ b/src/lib/services/deezer/downloader.ts @@ -2,7 +2,6 @@ * 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'; @@ -56,86 +55,21 @@ export async function downloadTrack( 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; - let lastReportedPercentage = 0; + // Use the provided decryption track ID (for fallback tracks) or the original track ID + const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; + // Download and decrypt in Rust backend (streaming, no memory accumulation) + console.log('Downloading and decrypting track in Rust backend...'); + await invoke('download_and_decrypt_track', { + url: downloadURL, + trackId: trackIdForDecryption, + outputPath: paths.tempPath, + isEncrypted: isCrypted + }); - chunks.push(value); - downloadedBytes += value.length; - - // Call progress callback every 5% - if (onProgress && totalSize > 0) { - const percentage = (downloadedBytes / totalSize) * 100; - const roundedPercentage = Math.floor(percentage / 5) * 5; - - if (roundedPercentage > lastReportedPercentage || percentage === 100) { - lastReportedPercentage = roundedPercentage; - 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}`); - - // Yield to the browser to keep UI responsive - await new Promise(resolve => setTimeout(resolve, 0)); - - // Decrypt if needed - let decryptedData: Uint8Array; - - if (isCrypted) { - console.log('Decrypting track...'); - // Use the provided decryption track ID (for fallback tracks) or the original track ID - const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString(); - console.log(`Decrypting with track ID: ${trackIdForDecryption}`); - // Call Rust decryption function - Tauri returns Vec as number[] - const decryptedArray = await invoke('decrypt_deezer_track', { - data: encryptedData, - trackId: trackIdForDecryption - }); - decryptedData = new Uint8Array(decryptedArray); - } else { - decryptedData = encryptedData; - } + console.log('Download and decryption complete!'); // Get user settings const appSettings = get(settings); @@ -151,10 +85,7 @@ export async function downloadTrack( } } - // Write untagged file to temp first - console.log('Writing untagged file to temp...'); - await writeFile(paths.tempPath, decryptedData); - + // File is already written to temp by Rust backend // Move to final location const finalPath = `${paths.filepath}/${paths.filename}`; console.log('Moving to final location:', finalPath);