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
This commit is contained in:
2025-10-04 20:53:59 -04:00
parent e4586f6497
commit 96a01bdced
5 changed files with 307 additions and 86 deletions

146
src-tauri/Cargo.lock generated
View File

@@ -8,9 +8,11 @@ version = "0.1.0"
dependencies = [ dependencies = [
"blowfish", "blowfish",
"byteorder", "byteorder",
"futures-util",
"id3", "id3",
"md5", "md5",
"metaflac", "metaflac",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -22,6 +24,7 @@ dependencies = [
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-sql", "tauri-plugin-sql",
"tauri-plugin-store", "tauri-plugin-store",
"tokio",
] ]
[[package]] [[package]]
@@ -659,7 +662,7 @@ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -1192,6 +1195,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 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]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -1199,7 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -1213,6 +1225,12 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -1844,6 +1862,22 @@ dependencies = [
"webpki-roots", "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]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.17" version = "0.1.17"
@@ -2464,6 +2498,23 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2858,6 +2909,50 @@ dependencies = [
"pathdiff", "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]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -3579,10 +3674,12 @@ dependencies = [
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls", "hyper-rustls",
"hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -3593,6 +3690,7 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls",
"tokio-rustls", "tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
@@ -3755,6 +3853,15 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3818,6 +3925,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "selectors" name = "selectors"
version = "0.24.0" version = "0.24.0"
@@ -4116,7 +4246,7 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"core-graphics", "core-graphics",
"foreign-types", "foreign-types 0.5.0",
"js-sys", "js-sys",
"log", "log",
"objc2 0.5.2", "objc2 0.5.2",
@@ -5085,6 +5215,16 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"

View File

@@ -33,4 +33,7 @@ tauri-plugin-process = "2"
blowfish = "0.9" blowfish = "0.9"
md5 = "0.7" md5 = "0.7"
byteorder = "1.5.0" 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"

View File

@@ -97,6 +97,69 @@ pub fn decrypt_track(data: &[u8], track_id: &str) -> Vec<u8> {
result result
} }
/// Streaming decryption state for processing data chunk-by-chunk
pub struct StreamingDecryptor {
blowfish_key: Vec<u8>,
buffer: Vec<u8>,
}
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<u8> {
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<u8> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -36,7 +36,7 @@ fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String>
metadata::read_audio_metadata(&path) metadata::read_audio_metadata(&path)
} }
/// Decrypt Deezer track data /// Decrypt Deezer track data (legacy - kept for backwards compatibility)
#[tauri::command] #[tauri::command]
async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> { async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> {
// Run decryption on a background thread to avoid blocking the UI // Run decryption on a background thread to avoid blocking the UI
@@ -49,6 +49,89 @@ async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>
Ok(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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let library_migrations = vec![Migration { let library_migrations = vec![Migration {
@@ -171,7 +254,8 @@ pub fn run() {
greet, greet,
tag_audio_file, tag_audio_file,
read_audio_metadata, read_audio_metadata,
decrypt_deezer_track decrypt_deezer_track,
download_and_decrypt_track
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -2,7 +2,6 @@
* Deezer track downloader with streaming and decryption * 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 { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { generateTrackPath } from './paths'; import { generateTrackPath } from './paths';
@@ -56,86 +55,21 @@ export async function downloadTrack(
console.log('Temp path:', paths.tempPath); console.log('Temp path:', paths.tempPath);
try { 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/'); 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;
while (true) {
const { done, value } = await reader.read();
if (done) break;
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 // Use the provided decryption track ID (for fallback tracks) or the original track ID
const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString(); const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString();
console.log(`Decrypting with track ID: ${trackIdForDecryption}`);
// Call Rust decryption function - Tauri returns Vec<u8> as number[] // Download and decrypt in Rust backend (streaming, no memory accumulation)
const decryptedArray = await invoke<number[]>('decrypt_deezer_track', { console.log('Downloading and decrypting track in Rust backend...');
data: encryptedData, await invoke('download_and_decrypt_track', {
trackId: trackIdForDecryption url: downloadURL,
trackId: trackIdForDecryption,
outputPath: paths.tempPath,
isEncrypted: isCrypted
}); });
decryptedData = new Uint8Array(decryptedArray);
} else { console.log('Download and decryption complete!');
decryptedData = encryptedData;
}
// Get user settings // Get user settings
const appSettings = get(settings); const appSettings = get(settings);
@@ -151,10 +85,7 @@ export async function downloadTrack(
} }
} }
// Write untagged file to temp first // File is already written to temp by Rust backend
console.log('Writing untagged file to temp...');
await writeFile(paths.tempPath, decryptedData);
// Move to final location // Move to final location
const finalPath = `${paths.filepath}/${paths.filename}`; const finalPath = `${paths.filepath}/${paths.filename}`;
console.log('Moving to final location:', finalPath); console.log('Moving to final location:', finalPath);