mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
refactor(lib_commands): centralise HTTP-stream-to-disk pattern in file_transfer
Pull the duplicated `subsonic UA + timeout client builder` and `stream → .part → rename → cleanup` flow out of cache/offline.rs, sync/device.rs, and sync/batch.rs into a new lib_commands/file_transfer module. - subsonic_http_client(timeout): standard UA + single timeout. Used by the 3 simple-timeout sites (offline track, sync_track, batch sync). download_update / download_zip / fetch_netease_lyrics keep their own builders since they need separate connect+overall timeouts or extra headers. - stream_to_file(response, dest): chunked HTTP body → file (moved from cache/offline.rs, where it was the de-facto shared helper anyway). - finalize_streamed_download(response, dest, part): stream to .part then rename, with best-effort cleanup of .part on any failure. Behaviour delta: the offline + device single-track flows now also clean up an orphan .part file when the final rename fails — previously only the batch sync did this. Strictly safer; no observable change on the success path. downloads.rs (download_update / download_zip) keeps its inline progress chunk loops — those emit per-chunk progress events with flow-specific payload shapes and intervals; sharing them would force a callback API that's heavier than the duplication it removes. Net delta: -53 LOC across the 3 call sites.
This commit is contained in:
+2
-31
@@ -2,24 +2,6 @@ use super::*;
|
|||||||
|
|
||||||
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Streams an HTTP response body directly to `dest_path` in small chunks.
|
|
||||||
/// Never buffers the full file in memory — keeps RAM flat regardless of file size.
|
|
||||||
pub(crate) async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path) -> Result<(), String> {
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
|
|
||||||
let mut file = tokio::fs::File::create(dest_path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
|
||||||
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
file.flush().await.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
|
pub(crate) async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
|
||||||
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||||
if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) {
|
if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) {
|
||||||
@@ -116,26 +98,15 @@ pub(crate) async fn download_track_offline(
|
|||||||
// and released automatically when this function returns (success or error).
|
// and released automatically when this function returns (success or error).
|
||||||
let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?;
|
let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
|
||||||
.user_agent(subsonic_wire_user_agent())
|
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream directly to a .part file; rename on success to avoid partial files.
|
|
||||||
let part_path = file_path.with_extension(format!("{suffix}.part"));
|
let part_path = file_path.with_extension(format!("{suffix}.part"));
|
||||||
if let Err(e) = stream_to_file(response, &part_path).await {
|
finalize_streamed_download(response, &file_path, &part_path).await?;
|
||||||
let _ = tokio::fs::remove_file(&part_path).await;
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
tokio::fs::rename(&part_path, &file_path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
|
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Build a reqwest client with the standard Subsonic UA and a single overall timeout.
|
||||||
|
/// For flows that need separate connect + read timeouts (long-running update/zip
|
||||||
|
/// downloads with progress events), build the client inline.
|
||||||
|
pub(crate) fn subsonic_http_client(timeout: Duration) -> Result<reqwest::Client, String> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.user_agent(super::subsonic_wire_user_agent())
|
||||||
|
.timeout(timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
|
||||||
|
/// file in memory — keeps RAM flat regardless of file size.
|
||||||
|
pub(crate) async fn stream_to_file(
|
||||||
|
response: reqwest::Response,
|
||||||
|
dest_path: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
let mut file = tokio::fs::File::create(dest_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||||
|
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
file.flush().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams `response` to `part_path`, then renames `part_path` → `dest_path`.
|
||||||
|
/// On any failure the partial `.part` file is best-effort removed so it does
|
||||||
|
/// not linger on disk. Caller must ensure `dest_path.parent()` exists.
|
||||||
|
///
|
||||||
|
/// Note vs. previous inline implementations: the offline/device single-track
|
||||||
|
/// flows used to leave a `.part` orphan if the final rename failed. This helper
|
||||||
|
/// always cleans up, matching the batch-sync flow that already did.
|
||||||
|
pub(crate) async fn finalize_streamed_download(
|
||||||
|
response: reqwest::Response,
|
||||||
|
dest_path: &Path,
|
||||||
|
part_path: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let Err(e) = stream_to_file(response, part_path).await {
|
||||||
|
let _ = tokio::fs::remove_file(part_path).await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
if let Err(e) = tokio::fs::rename(part_path, dest_path).await {
|
||||||
|
let _ = tokio::fs::remove_file(part_path).await;
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ use super::*;
|
|||||||
|
|
||||||
mod app_api;
|
mod app_api;
|
||||||
mod cache;
|
mod cache;
|
||||||
|
mod file_transfer;
|
||||||
mod sync;
|
mod sync;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
pub(crate) use app_api::*;
|
pub(crate) use app_api::*;
|
||||||
pub(crate) use cache::*;
|
pub(crate) use cache::*;
|
||||||
|
pub(crate) use file_transfer::*;
|
||||||
pub(crate) use sync::*;
|
pub(crate) use sync::*;
|
||||||
pub(crate) use ui::*;
|
pub(crate) use ui::*;
|
||||||
|
|||||||
@@ -137,11 +137,7 @@ pub(crate) async fn calculate_sync_payload(
|
|||||||
auth: SubsonicAuthPayload,
|
auth: SubsonicAuthPayload,
|
||||||
target_dir: String,
|
target_dir: String,
|
||||||
) -> Result<SyncDeltaResult, String> {
|
) -> Result<SyncDeltaResult, String> {
|
||||||
let client = reqwest::Client::builder()
|
let client = subsonic_http_client(std::time::Duration::from_secs(30))?;
|
||||||
.user_agent(subsonic_wire_user_agent())
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let mut add_bytes = 0;
|
let mut add_bytes = 0;
|
||||||
let mut add_count = 0;
|
let mut add_count = 0;
|
||||||
@@ -365,11 +361,7 @@ pub(crate) async fn sync_batch_to_device(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shared reqwest client — reused across all downloads.
|
// Shared reqwest client — reused across all downloads.
|
||||||
let client = reqwest::Client::builder()
|
let client = subsonic_http_client(Duration::from_secs(300))?;
|
||||||
.user_agent(subsonic_wire_user_agent())
|
|
||||||
.timeout(Duration::from_secs(300))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// Concurrency limiter: max 2 parallel USB writes.
|
// Concurrency limiter: max 2 parallel USB writes.
|
||||||
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
|
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
|
||||||
@@ -446,8 +438,7 @@ pub(crate) async fn sync_batch_to_device(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let part_path = dest_path.with_extension(format!("{}.part", track.suffix));
|
let part_path = dest_path.with_extension(format!("{}.part", track.suffix));
|
||||||
if let Err(e) = stream_to_file(response, &part_path).await {
|
if let Err(e) = finalize_streamed_download(response, &dest_path, &part_path).await {
|
||||||
let _ = tokio::fs::remove_file(&part_path).await;
|
|
||||||
f.fetch_add(1, Ordering::Relaxed);
|
f.fetch_add(1, Ordering::Relaxed);
|
||||||
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
||||||
"jobId": job, "trackId": track.id, "status": "error",
|
"jobId": job, "trackId": track.id, "status": "error",
|
||||||
@@ -455,15 +446,6 @@ pub(crate) async fn sync_batch_to_device(
|
|||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = tokio::fs::rename(&part_path, &dest_path).await {
|
|
||||||
let _ = tokio::fs::remove_file(&part_path).await;
|
|
||||||
f.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let _ = app2.emit("device:sync:progress", serde_json::json!({
|
|
||||||
"jobId": job, "trackId": track.id, "status": "error",
|
|
||||||
"error": e.to_string(),
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
d.fetch_add(1, Ordering::Relaxed);
|
d.fetch_add(1, Ordering::Relaxed);
|
||||||
status = "done";
|
status = "done";
|
||||||
|
|||||||
@@ -345,11 +345,7 @@ pub(crate) async fn sync_track_to_device(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = subsonic_http_client(std::time::Duration::from_secs(300))?;
|
||||||
.user_agent(subsonic_wire_user_agent())
|
|
||||||
.timeout(std::time::Duration::from_secs(300))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let response = client.get(&track.url).send().await.map_err(|e| e.to_string())?;
|
let response = client.get(&track.url).send().await.map_err(|e| e.to_string())?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
@@ -361,16 +357,12 @@ pub(crate) async fn sync_track_to_device(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let part_path = dest_path.with_extension(format!("{}.part", track.suffix));
|
let part_path = dest_path.with_extension(format!("{}.part", track.suffix));
|
||||||
if let Err(e) = stream_to_file(response, &part_path).await {
|
if let Err(e) = finalize_streamed_download(response, &dest_path, &part_path).await {
|
||||||
let _ = tokio::fs::remove_file(&part_path).await;
|
|
||||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||||
"jobId": job_id, "trackId": track.id, "status": "error", "error": e,
|
"jobId": job_id, "trackId": track.id, "status": "error", "error": e,
|
||||||
}));
|
}));
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
tokio::fs::rename(&part_path, &dest_path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||||
"jobId": job_id, "trackId": track.id, "status": "done", "path": path_str,
|
"jobId": job_id, "trackId": track.id, "status": "done", "path": path_str,
|
||||||
|
|||||||
Reference in New Issue
Block a user