fix(offline): cancellable downloads + stable sidebar progress toast (#694)

* 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
This commit is contained in:
Frank Stellmacher
2026-05-14 20:08:08 +02:00
committed by GitHub
parent 946528350c
commit b4c8ed4b65
12 changed files with 316 additions and 28 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ pub async fn download_track_hot_cache(
// Stream directly to a .part file; rename on success to avoid partial files.
let part_path = file_path.with_extension(format!("{suffix}.part"));
if let Err(e) = stream_to_file(response, &part_path).await {
if let Err(e) = stream_to_file(response, &part_path, None).await {
let _ = tokio::fs::remove_file(&part_path).await;
return Err(e);
}
+90 -8
View File
@@ -1,8 +1,11 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Manager;
use psysonic_analysis::analysis_cache;
use psysonic_analysis::analysis_runtime::enqueue_analysis_seed;
use crate::DownloadSemaphore;
use crate::{offline_cancel_flags, DownloadSemaphore};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
@@ -48,12 +51,16 @@ pub(crate) async fn read_seed_bytes_if_needed(
/// the cached path on hit, otherwise issues a GET via `client`, streams to
/// `<cache_dir>/<track_id>.<suffix>` via a `.part` file, and returns the
/// final path. Caller is responsible for the semaphore + analysis seeding.
///
/// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")`
/// (the `.part` file is cleaned up); `None` means the download is not cancellable.
pub(crate) async fn download_track_to_cache_dir(
cache_dir: &std::path::Path,
track_id: &str,
suffix: &str,
url: &str,
client: &reqwest::Client,
cancel: Option<&AtomicBool>,
) -> Result<std::path::PathBuf, String> {
tokio::fs::create_dir_all(cache_dir)
.await
@@ -70,7 +77,7 @@ pub(crate) async fn download_track_to_cache_dir(
}
let part_path = file_path.with_extension(format!("{suffix}.part"));
finalize_streamed_download(response, &file_path, &part_path).await?;
finalize_streamed_download(response, &file_path, &part_path, cancel).await?;
Ok(file_path)
}
@@ -98,12 +105,14 @@ pub(crate) fn resolve_offline_cache_dir(
/// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine.
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri command surface — args map 1:1 to the JS call.
pub async fn download_track_offline(
track_id: String,
server_id: String,
url: String,
suffix: String,
custom_dir: Option<String>,
download_id: Option<String>,
dl_sem: tauri::State<'_, DownloadSemaphore>,
app: tauri::AppHandle,
) -> Result<String, String> {
@@ -122,19 +131,68 @@ pub async fn download_track_offline(
return Ok(path_str);
}
// Resolve this download's cancellation flag. A missing `download_id` (e.g.
// an older caller) simply means the download cannot be cancelled.
let cancel_flag: Option<Arc<AtomicBool>> = download_id.as_deref().and_then(|id| {
offline_cancel_flags().lock().ok().map(|mut flags| {
flags
.entry(id.to_string())
.or_insert_with(|| Arc::new(AtomicBool::new(false)))
.clone()
})
});
// Acquire a download slot. The permit is held for the duration of the HTTP transfer
// and released automatically when this function returns (success or error).
let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?;
// Cancelled while parked on the semaphore — bail before opening a connection.
if cancel_flag.as_ref().is_some_and(|f| f.load(Ordering::Relaxed)) {
return Err("CANCELLED".to_string());
}
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
let final_path =
download_track_to_cache_dir(&cache_dir, &track_id, &suffix, &url, &client).await?;
let final_path = download_track_to_cache_dir(
&cache_dir,
&track_id,
&suffix,
&url,
&client,
cancel_flag.as_deref(),
)
.await?;
enqueue_analysis_seed_from_file(&app, &track_id, &final_path).await;
Ok(path_str)
}
/// Marks the given offline-download ids as cancelled. In-flight
/// `download_track_offline` calls abort their HTTP stream at the next chunk
/// boundary; ones still parked on the download semaphore bail as soon as they
/// acquire a slot. Mirrors `cancel_device_sync` for the device-sync side.
#[tauri::command]
pub fn cancel_offline_downloads(download_ids: Vec<String>) {
if let Ok(mut flags) = offline_cancel_flags().lock() {
for id in download_ids {
flags
.entry(id)
.or_insert_with(|| Arc::new(AtomicBool::new(false)))
.store(true, Ordering::Relaxed);
}
}
}
/// Drops a finished download's cancellation flag so the registry does not grow
/// across a long session. The frontend calls this once an album/playlist
/// download settles (completed or cancelled).
#[tauri::command]
pub fn clear_offline_cancel(download_id: String) {
if let Ok(mut flags) = offline_cancel_flags().lock() {
flags.remove(&download_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -156,7 +214,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/track-1", server.uri());
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client)
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
.await
.unwrap();
assert!(path.exists());
@@ -176,7 +234,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri());
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client)
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
.await
.unwrap();
assert_eq!(path, pre_existing);
@@ -197,7 +255,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/missing", server.uri());
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client)
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None)
.await
.unwrap_err();
assert!(err.contains("HTTP 404"), "got {err}");
@@ -221,12 +279,36 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri());
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client)
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None)
.await
.unwrap();
assert!(cache_dir.join("t.mp3").exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn download_to_cache_dir_aborts_and_cleans_up_when_cancelled() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/stream/track-1"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"flac body bytes".to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cache_dir = dir.path().join("psysonic-offline").join("server-A");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/track-1", server.uri());
let cancel = AtomicBool::new(true);
let err =
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, Some(&cancel))
.await
.unwrap_err();
assert_eq!(err, "CANCELLED");
assert!(!cache_dir.join("track-1.flac").exists(), "no final file on cancel");
assert!(!cache_dir.join("track-1.flac.part").exists(), "no .part orphan on cancel");
}
// ── delete_offline_track_with_boundary (AppHandle-free) ─────────────────
#[tokio::test(flavor = "multi_thread")]