mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
b4c8ed4b65
* fix(sidebar): keep offline-download toast from squishing in a short window The toast lives in the sidebar nav flex column; without flex-shrink: 0 the column compressed it vertically when the main window was small. The label now also ellipsis-truncates instead of overflowing on a narrow sidebar. * fix(offline): make offline downloads cancellable down to the Rust transfer A running offline download could not be stopped — the sidebar X button only dropped not-yet-started tracks between batches of 8, and the Rust transfer had no cancellation path at all, so in-flight HTTP streams always ran to completion. Add an offline_cancel_flags() registry (mirroring sync_cancel_flags for the device-sync side) plus additive cancel_offline_downloads / clear_offline_cancel commands. download_track_offline takes an optional download_id, checks the flag right after acquiring its semaphore slot, and threads it through finalize_streamed_download / stream_to_file so an in-flight stream aborts at the next chunk — the partial .part file is cleaned up by the existing error path. * fix(offline): cancel per-track and clear the sidebar toast immediately downloadAlbum tags each run with a downloadId, checks for cancellation before every track instead of once per 8-track batch (which never re-ran for albums of 8 or fewer tracks), and persists tracks that finished before the cancel so they are not orphaned on disk. cancelDownload / cancelAllDownloads drop every job for the album and call cancel_offline_downloads so Rust aborts the in-flight transfers — the toast disappears at once instead of lingering on stuck rows. Adds offlineJobStore cancellation tests. * docs(changelog): offline download cancel button + toast sizing fixes
252 lines
10 KiB
Rust
252 lines
10 KiB
Rust
use std::path::Path;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
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 fn subsonic_http_client(timeout: Duration) -> Result<reqwest::Client, String> {
|
|
reqwest::Client::builder()
|
|
.user_agent(psysonic_core::user_agent::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.
|
|
///
|
|
/// When `cancel` is supplied, the flag is checked before each chunk write: a set
|
|
/// flag aborts the transfer with `Err("CANCELLED")`, leaving the partial
|
|
/// `dest_path` for the caller to clean up. `None` means the transfer cannot be
|
|
/// cancelled (device-sync / hot-cache callers).
|
|
pub async fn stream_to_file(
|
|
response: reqwest::Response,
|
|
dest_path: &Path,
|
|
cancel: Option<&AtomicBool>,
|
|
) -> 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 {
|
|
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
|
|
return Err("CANCELLED".to_string());
|
|
}
|
|
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 — this includes a `cancel`-triggered abort. 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 async fn finalize_streamed_download(
|
|
response: reqwest::Response,
|
|
dest_path: &Path,
|
|
part_path: &Path,
|
|
cancel: Option<&AtomicBool>,
|
|
) -> Result<(), String> {
|
|
if let Err(e) = stream_to_file(response, part_path, cancel).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(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use wiremock::matchers::{method, path as wm_path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
#[test]
|
|
fn subsonic_http_client_builds_with_short_timeout() {
|
|
assert!(subsonic_http_client(Duration::from_secs(1)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn subsonic_http_client_builds_with_long_timeout() {
|
|
// The 5-minute timeout used by sync_track_to_device must construct successfully.
|
|
assert!(subsonic_http_client(Duration::from_secs(300)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn subsonic_http_client_builds_with_zero_timeout() {
|
|
// zero is a valid Duration — reqwest treats it as "no timeout effectively".
|
|
// The constructor must not reject it.
|
|
assert!(subsonic_http_client(Duration::from_secs(0)).is_ok());
|
|
}
|
|
|
|
// ── stream_to_file ────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn stream_to_file_writes_full_response_body() {
|
|
let server = MockServer::start().await;
|
|
let body = b"hello psysonic test bytes".to_vec();
|
|
Mock::given(method("GET"))
|
|
.and(wm_path("/track.flac"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let dest = dir.path().join("track.flac");
|
|
let response = reqwest::get(format!("{}/track.flac", server.uri()))
|
|
.await
|
|
.unwrap();
|
|
stream_to_file(response, &dest, None).await.unwrap();
|
|
|
|
let written = std::fs::read(&dest).unwrap();
|
|
assert_eq!(written, body);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn stream_to_file_creates_empty_file_for_empty_body() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(wm_path("/empty"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(Vec::<u8>::new()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let dest = dir.path().join("empty.bin");
|
|
let response = reqwest::get(format!("{}/empty", server.uri()))
|
|
.await
|
|
.unwrap();
|
|
stream_to_file(response, &dest, None).await.unwrap();
|
|
assert!(dest.exists());
|
|
assert_eq!(std::fs::metadata(&dest).unwrap().len(), 0);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn stream_to_file_returns_err_when_dest_directory_missing() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(wm_path("/x"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"x".to_vec()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let dest = dir.path().join("missing-subdir").join("x.bin");
|
|
let response = reqwest::get(format!("{}/x", server.uri()))
|
|
.await
|
|
.unwrap();
|
|
let result = stream_to_file(response, &dest, None).await;
|
|
assert!(result.is_err(), "create on missing parent dir must err");
|
|
}
|
|
|
|
// ── finalize_streamed_download ────────────────────────────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn finalize_renames_part_to_dest_on_success() {
|
|
let server = MockServer::start().await;
|
|
let body = b"final body content".to_vec();
|
|
Mock::given(method("GET"))
|
|
.and(wm_path("/track"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let dest = dir.path().join("track.flac");
|
|
let part = dest.with_extension("flac.part");
|
|
let response = reqwest::get(format!("{}/track", server.uri()))
|
|
.await
|
|
.unwrap();
|
|
|
|
finalize_streamed_download(response, &dest, &part, None).await.unwrap();
|
|
assert!(dest.exists(), "dest file must exist after success");
|
|
assert!(!part.exists(), "part file must not linger");
|
|
assert_eq!(std::fs::read(&dest).unwrap(), body);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn finalize_cleans_up_part_when_rename_fails() {
|
|
// Pre-create the dest as a directory — rename(file -> existing-dir)
|
|
// fails on every supported OS (renaming a file over a directory is
|
|
// not allowed, even when the dir is empty).
|
|
let server = MockServer::start().await;
|
|
let body = b"some content".to_vec();
|
|
Mock::given(method("GET"))
|
|
.and(wm_path("/track"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let dest = dir.path().join("blocker");
|
|
std::fs::create_dir(&dest).unwrap(); // dest is a dir → rename should fail
|
|
let part = dir.path().join("blocker.part");
|
|
let response = reqwest::get(format!("{}/track", server.uri()))
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = finalize_streamed_download(response, &dest, &part, None).await;
|
|
assert!(result.is_err(), "rename onto existing directory must fail");
|
|
assert!(!part.exists(), "part file must be cleaned up after rename failure");
|
|
assert!(dest.is_dir(), "the blocker directory itself stays untouched");
|
|
}
|
|
|
|
// ── cancellation ──────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn stream_to_file_aborts_when_cancel_flag_is_already_set() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(wm_path("/track"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"body bytes".to_vec()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let dest = dir.path().join("track.flac");
|
|
let response = reqwest::get(format!("{}/track", server.uri()))
|
|
.await
|
|
.unwrap();
|
|
|
|
let cancel = AtomicBool::new(true);
|
|
let result = stream_to_file(response, &dest, Some(&cancel)).await;
|
|
assert_eq!(result.unwrap_err(), "CANCELLED");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn finalize_cleans_up_part_when_cancelled() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(wm_path("/track"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"body bytes".to_vec()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let dest = dir.path().join("track.flac");
|
|
let part = dest.with_extension("flac.part");
|
|
let response = reqwest::get(format!("{}/track", server.uri()))
|
|
.await
|
|
.unwrap();
|
|
|
|
let cancel = AtomicBool::new(true);
|
|
let result = finalize_streamed_download(response, &dest, &part, Some(&cancel)).await;
|
|
assert_eq!(result.unwrap_err(), "CANCELLED");
|
|
assert!(!part.exists(), "cancelled transfer must not leave a .part orphan");
|
|
assert!(!dest.exists(), "cancelled transfer must not produce the final file");
|
|
}
|
|
}
|