diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a03a0e..aac6ab9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,12 @@ jobs: rust: runs-on: macos-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c with: + toolchain: stable components: rustfmt,clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace @@ -32,8 +33,8 @@ jobs: run: working-directory: apps/desktop steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 with: node-version: 22 cache: npm @@ -80,9 +81,9 @@ jobs: bundle: deb output: deb steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 with: node-version: 22 cache: npm @@ -109,9 +110,11 @@ jobs: patchelf \ wget - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + with: + toolchain: stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Install frontend dependencies working-directory: apps/desktop @@ -129,12 +132,6 @@ jobs: - name: Release native credential-store tests run: cargo test --workspace --release --locked - - name: Verify local-only import and playback on non-macOS - if: startsWith(matrix.os, 'windows-') || startsWith(matrix.os, 'ubuntu-') - run: | - cargo test -p retune-audio --release - cargo test -p retune-desktop --release local_files_remain_playable_without_playback_authorization - - name: Build native bundle working-directory: apps/desktop run: npx tauri build --bundles ${{ matrix.bundle }} diff --git a/README.md b/README.md index c2b6bdb..a65a859 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Retune is for people who want to maintain a music library, not just stream one. | Platform | Package | Supported architecture | | --- | --- | --- | -| macOS 11+ | Homebrew | Apple Silicon | +| macOS 15+ (Apple-supported releases) | Homebrew | Apple Silicon | | Windows 10/11 (WebView2 105+) | Winget | x64, ARM64 | | Ubuntu 22.04 and compatible Debian/Ubuntu | APT | amd64, arm64 | diff --git a/apps/desktop/src-tauri/src/artwork_cache.rs b/apps/desktop/src-tauri/src/artwork_cache.rs new file mode 100644 index 0000000..cf68568 --- /dev/null +++ b/apps/desktop/src-tauri/src/artwork_cache.rs @@ -0,0 +1,495 @@ +use std::{ + fs::{self, File, FileTimes}, + io, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use md5::{Digest, Md5}; +use retune_audio::Artwork; + +use crate::persistence; + +const MAGIC: &[u8; 8] = b"RTNART01"; +const FIXED_HEADER_BYTES: usize = 40; +const MAX_MIME_BYTES: usize = 255; +const MAX_CACHE_ENTRY_BYTES: u64 = + FIXED_HEADER_BYTES as u64 + MAX_MIME_BYTES as u64 + MAX_LOCAL_ARTWORK_BYTES as u64; + +pub(crate) const CACHE_LIMIT_BYTES: u64 = 512 * 1024 * 1024; +pub(crate) const MAX_LOCAL_ARTWORK_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SourceStamp { + length: u64, + modified_seconds: u64, + modified_nanos: u32, +} + +impl SourceStamp { + fn read(path: &Path) -> io::Result { + let metadata = fs::metadata(path)?; + let modified = metadata + .modified()? + .duration_since(UNIX_EPOCH) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + Ok(Self { + length: metadata.len(), + modified_seconds: modified.as_secs(), + modified_nanos: modified.subsec_nanos(), + }) + } +} + +#[derive(Clone)] +pub(crate) struct LocalArtworkCache { + root: PathBuf, + max_bytes: u64, + io_gate: Arc>, +} + +impl LocalArtworkCache { + pub(crate) fn new(root: impl Into) -> Self { + Self::with_max_bytes(root, CACHE_LIMIT_BYTES) + } + + fn with_max_bytes(root: impl Into, max_bytes: u64) -> Self { + Self { + root: root.into(), + max_bytes, + io_gate: Arc::new(Mutex::new(())), + } + } + + pub(crate) fn get_or_load( + &self, + uri: &str, + source_path: &Path, + load: F, + ) -> Result, String> + where + F: FnOnce(&Path) -> Result, String>, + { + let _guard = self + .io_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let cache_path = self.entry_path(uri); + let source_before = SourceStamp::read(source_path).ok(); + + if let Some(source_before) = source_before { + match read_entry(&cache_path, source_before) { + Ok(artwork) if SourceStamp::read(source_path).ok() == Some(source_before) => { + touch(&cache_path); + return Ok(Some(artwork)); + } + Ok(_) => { + let _ = fs::remove_file(&cache_path); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => { + let _ = fs::remove_file(&cache_path); + } + } + } + + let artwork = load(source_path)?; + if let (Some(source_before), Some(artwork)) = (source_before, artwork.as_ref()) { + if SourceStamp::read(source_path).ok() == Some(source_before) { + self.store_entry(&cache_path, source_before, artwork); + } + } + Ok(artwork) + } + + fn entry_path(&self, uri: &str) -> PathBuf { + let digest = Md5::digest(uri.as_bytes()); + self.root.join(format!("{digest:x}")) + } + + fn store_entry(&self, path: &Path, source: SourceStamp, artwork: &Artwork) { + let Some(encoded) = encode_entry(source, artwork) else { + return; + }; + let entry_bytes = encoded.len() as u64; + if entry_bytes > self.max_bytes + || self + .prune_other_entries(self.max_bytes - entry_bytes, path) + .is_err() + { + return; + } + let _ = persistence::atomic_write(path, &encoded, Some(0o600)); + } + + fn prune_other_entries(&self, max_bytes: u64, replacing: &Path) -> io::Result<()> { + // ponytail: scan owned entries on writes; add an index only if profiling finds this hot. + let entries = match fs::read_dir(&self.root) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + let mut files = Vec::new(); + let mut total = 0u64; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path == replacing || !is_cache_entry_name(&entry.file_name()) { + continue; + } + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_file() => metadata, + _ => continue, + }; + let modified = metadata.modified().unwrap_or(UNIX_EPOCH); + let length = metadata.len(); + total = total.saturating_add(length); + files.push((path, length, modified)); + } + if total <= max_bytes { + return Ok(()); + } + files.sort_by_key(|(_, _, modified)| *modified); + for (path, length, _) in files { + if total <= max_bytes { + break; + } + if fs::remove_file(path).is_ok() { + total = total.saturating_sub(length); + } + } + if total > max_bytes { + return Err(io::Error::other( + "could not trim artwork cache to its byte limit", + )); + } + Ok(()) + } +} + +fn encode_entry(source: SourceStamp, artwork: &Artwork) -> Option> { + if artwork.bytes.len() > MAX_LOCAL_ARTWORK_BYTES { + return None; + } + let mime = artwork + .mime + .as_deref() + .unwrap_or("application/octet-stream"); + if mime.is_empty() + || mime.len() > MAX_MIME_BYTES + || !mime + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b';' && byte != b',') + { + return None; + } + + let mut encoded = Vec::with_capacity(FIXED_HEADER_BYTES + mime.len() + artwork.bytes.len()); + encoded.extend_from_slice(MAGIC); + encoded.extend_from_slice(&source.length.to_le_bytes()); + encoded.extend_from_slice(&source.modified_seconds.to_le_bytes()); + encoded.extend_from_slice(&source.modified_nanos.to_le_bytes()); + encoded.extend_from_slice(&(mime.len() as u32).to_le_bytes()); + encoded.extend_from_slice(&(artwork.bytes.len() as u64).to_le_bytes()); + encoded.extend_from_slice(mime.as_bytes()); + encoded.extend_from_slice(&artwork.bytes); + Some(encoded) +} + +fn read_entry(path: &Path, expected_source: SourceStamp) -> io::Result { + let mut encoded = persistence::read_limited(path, MAX_CACHE_ENTRY_BYTES)?; + if encoded.len() < FIXED_HEADER_BYTES || encoded.get(..MAGIC.len()) != Some(MAGIC.as_slice()) { + return Err(invalid_entry()); + } + + let length = read_u64(&encoded, 8)?; + let modified_seconds = read_u64(&encoded, 16)?; + let modified_nanos = read_u32(&encoded, 24)?; + let mime_length = read_u32(&encoded, 28)? as usize; + let artwork_length = read_u64(&encoded, 32)?; + let source = SourceStamp { + length, + modified_seconds, + modified_nanos, + }; + if source != expected_source + || mime_length == 0 + || mime_length > MAX_MIME_BYTES + || artwork_length > MAX_LOCAL_ARTWORK_BYTES as u64 + { + return Err(invalid_entry()); + } + let payload_start = FIXED_HEADER_BYTES + .checked_add(mime_length) + .ok_or_else(invalid_entry)?; + let expected_length = (payload_start as u64) + .checked_add(artwork_length) + .ok_or_else(invalid_entry)?; + if expected_length != encoded.len() as u64 { + return Err(invalid_entry()); + } + let mime = std::str::from_utf8(&encoded[FIXED_HEADER_BYTES..payload_start]) + .map_err(|_| invalid_entry())? + .to_owned(); + if !mime + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b';' && byte != b',') + { + return Err(invalid_entry()); + } + encoded.drain(..payload_start); + Ok(Artwork { + mime: Some(mime), + bytes: encoded, + }) +} + +fn read_u32(bytes: &[u8], offset: usize) -> io::Result { + let value = bytes.get(offset..offset + 4).ok_or_else(invalid_entry)?; + Ok(u32::from_le_bytes( + value.try_into().expect("four-byte slice"), + )) +} + +fn read_u64(bytes: &[u8], offset: usize) -> io::Result { + let value = bytes.get(offset..offset + 8).ok_or_else(invalid_entry)?; + Ok(u64::from_le_bytes( + value.try_into().expect("eight-byte slice"), + )) +} + +fn invalid_entry() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "invalid artwork cache entry") +} + +fn touch(path: &Path) { + let Ok(file) = File::options().write(true).open(path) else { + return; + }; + let _ = file.set_times(FileTimes::new().set_modified(SystemTime::now())); +} + +fn is_cache_entry_name(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + name.len() == 32 && name.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + fs::{File, FileTimes}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, UNIX_EPOCH}, + }; + + use super::{encode_entry, LocalArtworkCache, SourceStamp}; + use retune_audio::Artwork; + + fn artwork(bytes: &[u8]) -> Artwork { + Artwork { + mime: Some("image/test".into()), + bytes: bytes.to_vec(), + } + } + + fn source(dir: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let path = dir.join(name); + fs::write(&path, bytes).unwrap(); + path + } + + #[test] + fn same_uri_reuses_raw_artwork_without_a_width_key() { + let dir = tempfile::tempdir().unwrap(); + let path = source(dir.path(), "track.mp3", b"audio source"); + let cache = LocalArtworkCache::new(dir.path().join("artwork-cache")); + let reads = Arc::new(AtomicUsize::new(0)); + let reader = Arc::clone(&reads); + let first = cache + .get_or_load("file:///track.mp3", &path, move |_| { + reader.fetch_add(1, Ordering::SeqCst); + Ok(Some(artwork(b"raw image"))) + }) + .unwrap() + .unwrap(); + let second = cache + .get_or_load("file:///track.mp3", &path, |_| { + panic!("a valid cache entry should skip extraction") + }) + .unwrap() + .unwrap(); + + assert_eq!(reads.load(Ordering::SeqCst), 1); + assert_eq!(first, second); + let entry = fs::read(cache.entry_path("file:///track.mp3")).unwrap(); + assert!(entry.starts_with(b"RTNART01")); + assert!(!entry + .windows(b"data:image/test;base64".len()) + .any(|window| window == b"data:image/test;base64")); + } + + #[test] + fn source_modification_invalidates_its_entry() { + let dir = tempfile::tempdir().unwrap(); + let path = source(dir.path(), "track.mp3", b"audio source"); + let cache = LocalArtworkCache::new(dir.path().join("artwork-cache")); + let reads = Arc::new(AtomicUsize::new(0)); + for expected in [b"old".as_slice(), b"newer image".as_slice()] { + let reader = Arc::clone(&reads); + let artwork = cache + .get_or_load("file:///track.mp3", &path, move |_| { + reader.fetch_add(1, Ordering::SeqCst); + Ok(Some(artwork(expected))) + }) + .unwrap() + .unwrap(); + if expected == b"old" { + assert_eq!(artwork.bytes, b"old"); + fs::write(&path, b"changed source with a different length").unwrap(); + } else { + assert_eq!(artwork.bytes, b"newer image"); + } + } + assert_eq!(reads.load(Ordering::SeqCst), 2); + } + + #[test] + fn source_change_during_extraction_does_not_cache_stale_artwork() { + let dir = tempfile::tempdir().unwrap(); + let path = source(dir.path(), "track.mp3", b"original source"); + let cache = LocalArtworkCache::new(dir.path().join("artwork-cache")); + let first = cache + .get_or_load("file:///track.mp3", &path, |path| { + fs::write(path, b"replacement source with a different length").unwrap(); + Ok(Some(artwork(b"stale artwork"))) + }) + .unwrap() + .unwrap(); + assert_eq!(first.bytes, b"stale artwork"); + + let reads = Arc::new(AtomicUsize::new(0)); + let reader = Arc::clone(&reads); + let second = cache + .get_or_load("file:///track.mp3", &path, move |_| { + reader.fetch_add(1, Ordering::SeqCst); + Ok(Some(artwork(b"current artwork"))) + }) + .unwrap() + .unwrap(); + + assert_eq!(second.bytes, b"current artwork"); + assert_eq!(reads.load(Ordering::SeqCst), 1); + } + + #[test] + fn corrupt_cache_entry_falls_back_to_source_reader() { + let dir = tempfile::tempdir().unwrap(); + let path = source(dir.path(), "track.mp3", b"audio source"); + let cache = LocalArtworkCache::new(dir.path().join("artwork-cache")); + cache + .get_or_load("file:///track.mp3", &path, |_| Ok(Some(artwork(b"first")))) + .unwrap(); + fs::write(cache.entry_path("file:///track.mp3"), b"broken").unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let reader = Arc::clone(&reads); + let result = cache + .get_or_load("file:///track.mp3", &path, move |_| { + reader.fetch_add(1, Ordering::SeqCst); + Ok(Some(artwork(b"recovered"))) + }) + .unwrap() + .unwrap(); + + assert_eq!(result.bytes, b"recovered"); + assert_eq!(reads.load(Ordering::SeqCst), 1); + } + + #[test] + fn unwritable_cache_falls_back_to_source_reader() { + let dir = tempfile::tempdir().unwrap(); + let path = source(dir.path(), "track.mp3", b"audio source"); + let cache_dir = dir.path().join("artwork-cache"); + fs::write(&cache_dir, b"cache directory blocked").unwrap(); + let cache = LocalArtworkCache::new(cache_dir); + let reads = Arc::new(AtomicUsize::new(0)); + + for _ in 0..2 { + let reader = Arc::clone(&reads); + let result = cache + .get_or_load("file:///track.mp3", &path, move |_| { + reader.fetch_add(1, Ordering::SeqCst); + Ok(Some(artwork(b"available despite cache error"))) + }) + .unwrap() + .unwrap(); + assert_eq!(result.bytes, b"available despite cache error"); + } + + assert_eq!(reads.load(Ordering::SeqCst), 2); + } + + #[test] + fn cache_limit_evicts_oldest_entry_and_keeps_recent_hit() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = dir.path().join("artwork-cache"); + let first = source(dir.path(), "first.mp3", b"first source"); + let second = source(dir.path(), "second.mp3", b"second source"); + let third = source(dir.path(), "third.mp3", b"third source"); + let first_art = artwork(b"first image"); + let stamp = SourceStamp::read(&first).unwrap(); + let two_entries = (encode_entry(stamp, &first_art).unwrap().len() * 2) as u64; + let cache = LocalArtworkCache::with_max_bytes(&cache_dir, two_entries); + + cache + .get_or_load("file:///first.mp3", &first, |_| Ok(Some(first_art.clone()))) + .unwrap(); + cache + .get_or_load("file:///second.mp3", &second, |_| { + Ok(Some(artwork(b"first image"))) + }) + .unwrap(); + let first_entry = cache.entry_path("file:///first.mp3"); + let second_entry = cache.entry_path("file:///second.mp3"); + let old = UNIX_EPOCH + Duration::from_secs(1); + File::options() + .write(true) + .open(&first_entry) + .unwrap() + .set_times(FileTimes::new().set_modified(old)) + .unwrap(); + let recent = old + Duration::from_secs(1); + File::options() + .write(true) + .open(&second_entry) + .unwrap() + .set_times(FileTimes::new().set_modified(recent)) + .unwrap(); + + cache + .get_or_load("file:///first.mp3", &first, |_| { + panic!("cache hit should update recency without reading source") + }) + .unwrap(); + cache + .get_or_load("file:///third.mp3", &third, |_| { + Ok(Some(artwork(b"first image"))) + }) + .unwrap(); + + assert!(first_entry.exists()); + assert!(!second_entry.exists()); + assert!(cache.entry_path("file:///third.mp3").exists()); + let total = fs::read_dir(&cache_dir) + .unwrap() + .map(|entry| entry.unwrap().metadata().unwrap().len()) + .sum::(); + assert!(total <= two_entries); + } +} diff --git a/apps/desktop/src-tauri/src/backup.rs b/apps/desktop/src-tauri/src/backup.rs index 44f19be..7be00b7 100644 --- a/apps/desktop/src-tauri/src/backup.rs +++ b/apps/desktop/src-tauri/src/backup.rs @@ -541,48 +541,82 @@ pub(super) fn commit_restore( playlists: imported_playlists.is_some(), }; let restore_store = restore::RestoreStore::new(app_data_dir); - restore_store - .begin(&journal) - .map_err(|error| error.to_string())?; - - let primary = (|| { - library_restore.replace(imported)?; - if let Some(settings) = after_settings.as_ref() { - settings_restore.replace(settings.clone())?; - } - if let Some(playlists) = imported_playlists.as_ref() { - playlist_restore.replace(playlists.clone())?; - } - if let Some(mappings) = after_mappings.as_ref() { - mappings_restore.replace(mappings.clone())?; - } - restore_store - .complete(&journal) - .map_err(|error| error.to_string()) - })(); + let primary = match restore_store.begin(&journal) { + Ok(()) => (|| { + library_restore.replace(imported)?; + if let Some(settings) = after_settings.as_ref() { + settings_restore.replace(settings.clone())?; + } + if let Some(playlists) = imported_playlists.as_ref() { + playlist_restore.replace(playlists.clone())?; + } + if let Some(mappings) = after_mappings.as_ref() { + mappings_restore.replace(mappings.clone())?; + } + restore_store + .complete(&journal) + .map_err(|error| error.to_string()) + })(), + Err(begin_error) => match restore_store.recover_exact(&journal) { + Ok(true) => Ok(()), + Ok(false) => { + drop(mappings_restore); + drop(playlist_restore); + drop(settings_restore); + drop(library_restore); + return Err(begin_error.to_string()); + } + Err(recovery_error) => { + restore_mutations.mark_recovery_required(); + drop(mappings_restore); + drop(playlist_restore); + drop(settings_restore); + drop(library_restore); + return Err(format!( + "Restore journal could not be started ({begin_error}) and immediate recovery failed ({recovery_error}). Restart Retune to recover before making more changes." + )); + } + }, + }; if let Err(primary_error) = primary { - if let Err(recovery_error) = restore_store.recover() { - restore_mutations.mark_recovery_required(); - drop(mappings_restore); - drop(playlist_restore); - drop(settings_restore); - drop(library_restore); - return Err(format!( - "Restore failed ({primary_error}) and immediate recovery failed ({recovery_error}). Restart Retune to recover before making more changes." - )); - } - log::warn!("Restore write failed but was rolled forward immediately: {primary_error}"); - library_restore.install_recovered(journal.library.after.clone()); - if let Some(change) = &journal.settings { - settings_restore.install_recovered(change.after.clone()); - } - if let Some(change) = &journal.playlists { - playlist_restore.install_recovered(change.after.clone()); - } - if let Some(change) = &journal.lastfm_mappings { - mappings_restore.install_recovered(change.after.clone()); + match restore_store.recover_exact(&journal) { + Ok(true) => { + log::warn!( + "Restore write failed but was rolled forward immediately: {primary_error}" + ); + } + Ok(false) => { + restore_mutations.mark_recovery_required(); + drop(mappings_restore); + drop(playlist_restore); + drop(settings_restore); + drop(library_restore); + return Err(format!( + "Restore failed ({primary_error}) and its journal could not be matched for immediate recovery. Restart Retune to recover before making more changes." + )); + } + Err(recovery_error) => { + restore_mutations.mark_recovery_required(); + drop(mappings_restore); + drop(playlist_restore); + drop(settings_restore); + drop(library_restore); + return Err(format!( + "Restore failed ({primary_error}) and immediate recovery failed ({recovery_error}). Restart Retune to recover before making more changes." + )); + } } } + library_restore.install_recovered(journal.library.after.clone()); + if let Some(change) = &journal.settings { + settings_restore.install_recovered(change.after.clone()); + } + if let Some(change) = &journal.playlists { + playlist_restore.install_recovered(change.after.clone()); + } + if let Some(change) = &journal.lastfm_mappings { + mappings_restore.install_recovered(change.after.clone()); + } drop(mappings_restore); drop(playlist_restore); drop(settings_restore); diff --git a/apps/desktop/src-tauri/src/lastfm.rs b/apps/desktop/src-tauri/src/lastfm.rs index 5767796..b250b82 100644 --- a/apps/desktop/src-tauri/src/lastfm.rs +++ b/apps/desktop/src-tauri/src/lastfm.rs @@ -31,6 +31,8 @@ pub(crate) use listening::{AcceptedScrobbleReceipt, ScrobbleMetadata}; use store::*; const AUTH_URL: &str = "https://www.last.fm/api/auth"; +const STORAGE_PROBLEM: &str = + "Last.fm is unavailable because local persistence could not be trusted. Restart Retune to recover."; pub(crate) const CREDENTIAL_SERVICE: &str = "com.rianjs.retune"; pub(crate) const SESSION_ACCOUNT: &str = "lastfm-session"; const RETRY_DELAYS: &[Duration] = &[ @@ -152,6 +154,7 @@ fn load_persisted_lastfm( let (ledger, migrated) = match queue_store.load_ledger_with_migration() { Ok(result) => result, Err(error) => { + storage_problem = true; log::error!( "Last.fm local persistence failed; queued scrobbles may be unavailable: {error}" ); @@ -357,6 +360,13 @@ impl Service { self.persist_ledger(queue, accepted, owner).await } + async fn record_storage_result(&self, result: Result) -> Result { + if result.is_err() { + self.runtime.lock().await.storage_problem = true; + } + result + } + async fn persist_ledger( &self, pending: VecDeque, @@ -364,7 +374,7 @@ impl Service { owner: Option, ) -> Result<(), String> { let store = self.queue_store.clone(); - tauri::async_runtime::spawn_blocking(move || { + let result = tauri::async_runtime::spawn_blocking(move || { store.save_ledger(&ScrobbleLedgerV2 { version: SCROBBLE_LEDGER_VERSION, pending, @@ -373,42 +383,54 @@ impl Service { }) }) .await - .map_err(|_| "Last.fm queue persistence task stopped.".to_string())? + .map_err(|error| format!("Last.fm queue persistence task stopped: {error}")) + .and_then(|result| result); + self.record_storage_result(result).await } async fn load_pending(&self) -> Result, String> { let store = self.pending_store.clone(); - tauri::async_runtime::spawn_blocking(move || store.load()) + let result = tauri::async_runtime::spawn_blocking(move || store.load()) .await - .map_err(|_| "Last.fm pending-token task stopped.".to_string())? + .map_err(|error| format!("Last.fm pending-token task stopped: {error}")) + .and_then(|result| result); + self.record_storage_result(result).await } async fn save_pending(&self, token: String) -> Result<(), String> { let store = self.pending_store.clone(); - tauri::async_runtime::spawn_blocking(move || store.save(&token)) + let result = tauri::async_runtime::spawn_blocking(move || store.save(&token)) .await - .map_err(|_| "Last.fm pending-token task stopped.".to_string())? + .map_err(|error| format!("Last.fm pending-token task stopped: {error}")) + .and_then(|result| result); + self.record_storage_result(result).await } async fn clear_pending(&self) -> Result<(), String> { let store = self.pending_store.clone(); - tauri::async_runtime::spawn_blocking(move || store.clear()) + let result = tauri::async_runtime::spawn_blocking(move || store.clear()) .await - .map_err(|_| "Last.fm pending-token task stopped.".to_string())? + .map_err(|error| format!("Last.fm pending-token task stopped: {error}")) + .and_then(|result| result); + self.record_storage_result(result).await } async fn save_session(&self, session: LastFmSession) -> Result<(), String> { let store = Arc::clone(&self.session_store); - tauri::async_runtime::spawn_blocking(move || store.save(&session)) + let result = tauri::async_runtime::spawn_blocking(move || store.save(&session)) .await - .map_err(|_| "Last.fm session-store task stopped.".to_string())? + .map_err(|error| format!("Last.fm session-store task stopped: {error}")) + .and_then(|result| result); + self.record_storage_result(result).await } async fn clear_session(&self) -> Result<(), String> { let store = Arc::clone(&self.session_store); - tauri::async_runtime::spawn_blocking(move || store.clear()) + let result = tauri::async_runtime::spawn_blocking(move || store.clear()) .await - .map_err(|_| "Last.fm session-store task stopped.".to_string())? + .map_err(|error| format!("Last.fm session-store task stopped: {error}")) + .and_then(|result| result); + self.record_storage_result(result).await } pub(crate) async fn state(&self) -> LastFmState { @@ -429,13 +451,7 @@ impl Service { Some("Last.fm is unavailable in this build because its app credentials are not configured.".into()), ) } else if runtime.storage_problem { - ( - false, - Some( - "Last.fm is unavailable because secure credential storage could not be opened." - .into(), - ), - ) + (false, Some(STORAGE_PROBLEM.into())) } else if runtime.build_problem { ( false, @@ -475,6 +491,14 @@ impl Service { self.accepted_receipts.lock().await.clone() } + async fn ensure_storage_available(&self) -> Result<(), String> { + if self.runtime.lock().await.storage_problem { + Err(STORAGE_PROBLEM.into()) + } else { + Ok(()) + } + } + pub(crate) async fn reconciliation_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { self.reconciliation_io.lock().await } @@ -488,6 +512,7 @@ impl Service { return Ok(()); } let _queue_io = self.queue_io.lock().await; + self.ensure_storage_available().await?; let original = self.accepted_receipts.lock().await.clone(); let mut next = original.clone(); for receipt in consumed { @@ -532,6 +557,7 @@ impl Service { { self.ensure_available().await?; let _credential_io = self.credential_io.lock().await; + self.ensure_storage_available().await?; let owns_import = self .runtime .lock() @@ -648,6 +674,7 @@ impl Service { .filter(|token| !token.is_empty()) .ok_or_else(|| "Last.fm did not return an authorization token.".to_string())?; let _credential_io = self.credential_io.lock().await; + self.ensure_storage_available().await?; self.save_pending(token.clone()).await?; { let mut runtime = self.runtime.lock().await; @@ -708,6 +735,7 @@ impl Service { let service = Arc::clone(self); tauri::async_runtime::spawn(async move { let _credential_io = service.credential_io.lock().await; + service.ensure_storage_available().await?; let previous_username = service .runtime .lock() @@ -765,6 +793,7 @@ impl Service { let _credential_io = self.credential_io.lock().await; let _reconciliation_io = self.reconciliation_guard().await; let _queue_io = self.queue_io.lock().await; + self.ensure_storage_available().await?; let (previous_queue, previous_owner, revision) = { let mut runtime = self.runtime.lock().await; let previous_queue = runtime.queue.clone(); @@ -1184,6 +1213,9 @@ impl Service { let _queue_io = self.queue_io.lock().await; let (original, queue, removed, revision, owner) = { let mut runtime = self.runtime.lock().await; + if runtime.storage_problem { + return FlushOutcome::Stop; + } if !queue_starts_with(&runtime.queue, &batch) || runtime.session.as_ref() != Some(&session) { @@ -1264,6 +1296,9 @@ impl Service { let _queue_io = self.queue_io.lock().await; let (original, queue, removed, revision) = { let mut runtime = self.runtime.lock().await; + if runtime.storage_problem { + return FlushOutcome::Stop; + } if !queue_starts_with(&runtime.queue, &batch) || runtime.session.as_ref() != Some(&session) { @@ -1320,6 +1355,9 @@ impl Service { async fn invalidate_session_owned(&self, expected: LastFmSession) -> bool { let _credential_io = self.credential_io.lock().await; + if self.ensure_storage_available().await.is_err() { + return false; + } let current = self.runtime.lock().await.session.clone(); if current.as_ref() != Some(&expected) { return false; @@ -1381,6 +1419,7 @@ impl Service { async fn reconcile_queue_owner(&self, username: &str) -> Result<(), String> { let _reconciliation_io = self.reconciliation_guard().await; let _queue_io = self.queue_io.lock().await; + self.ensure_storage_available().await?; let (revision, queue_owned) = { let runtime = self.runtime.lock().await; ( diff --git a/apps/desktop/src-tauri/src/lastfm/store.rs b/apps/desktop/src-tauri/src/lastfm/store.rs index 78494b2..65433f2 100644 --- a/apps/desktop/src-tauri/src/lastfm/store.rs +++ b/apps/desktop/src-tauri/src/lastfm/store.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use std::{ collections::VecDeque, - fs, path::{Path, PathBuf}, }; @@ -242,9 +241,6 @@ fn write_secret_json(path: &Path, value: &T) -> Result<(), String> .map_err(|_| "Could not save the Last.fm credential.".to_string()) } fn remove_file(path: &Path) -> Result<(), String> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => Err("Could not remove a Last.fm local store.".into()), - } + crate::persistence::durable_remove(path) + .map_err(|_| "Could not remove a Last.fm local store.".into()) } diff --git a/apps/desktop/src-tauri/src/lastfm_import.rs b/apps/desktop/src-tauri/src/lastfm_import.rs index f24c084..4a50cc7 100644 --- a/apps/desktop/src-tauri/src/lastfm_import.rs +++ b/apps/desktop/src-tauri/src/lastfm_import.rs @@ -117,8 +117,7 @@ pub(crate) use service::Service; use source::{ aggregate_incremental_scrobbles, discard_post_cutoff, download_page_window_with_checkpoint, fetch_incremental_page_with_retry, fetch_source_page, read_incremental_events, run_import, - snapshot_cache_id, sort_scrobbles, startup_lastfm_identity_matches, startup_resume_plan, - SourceWindowOutcome, + snapshot_cache_id, sort_scrobbles, SourceWindowOutcome, }; pub(crate) use source::{aggregate_scrobbles, normalize_for_match, parse_recent_tracks_page}; #[cfg(test)] @@ -953,16 +952,15 @@ async fn lastfm_username(lastfm: &crate::lastfm::Service) -> Result( +async fn ensure_import_readable( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl Fn() -> Result, ) -> Result where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let Some(owner) = service.owner_phase().await else { return Ok(true); @@ -1004,17 +1002,16 @@ where } } -async fn current_spotify_binding_is_current( +async fn current_spotify_binding_is_current( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, allow_suspended: bool, ) -> Result where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let Some(owner) = service.owner_phase().await else { return Ok(false); @@ -1097,15 +1094,15 @@ where Fut: Future>, { let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; for (batch_id, artist, album) in batch_match_plan(&session, None) { prepare(batch_id, artist, album).await?; } let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let (albums, tracks) = accept_all_entity_uris(&session); Ok(AcceptAllSummary { @@ -1122,12 +1119,12 @@ async fn select_best_matches_for_batch( artist: &str, album: &str, ) -> Result<(), String> { - let Some(page) = service.page(batch_id, artist, album).await else { + let Some(page) = service.page_for_work(batch_id, artist, album).await? else { return Err("Unknown Last.fm import review batch.".into()); }; let collection_shaped = service - .snapshot() - .await + .snapshot_for_work() + .await? .is_some_and(|session| batch_is_collection_shaped_for_id(&session, batch_id)); let mut selected_album_uris = BTreeSet::new(); for item in page.rows { @@ -1191,7 +1188,7 @@ async fn enqueue_next_accept_all_job(service: &Service) -> Result } let username = cursor.lastfm_username.clone(); let spotify_account_id = cursor.spotify_account_id.clone(); - let Some(session) = service.snapshot().await else { + let Some(session) = service.snapshot_for_work().await? else { return Err("No Last.fm import session is active.".into()); }; if session.cache_id != cursor.session_id @@ -1260,8 +1257,8 @@ async fn enqueue_next_accept_all_job(service: &Service) -> Result ) .await?; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let options = session.options_for_page_batch(&batch, &artist, &album, &rows); let selected_ids = options diff --git a/apps/desktop/src-tauri/src/lastfm_import/application.rs b/apps/desktop/src-tauri/src/lastfm_import/application.rs index c1740c5..b97599e 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/application.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/application.rs @@ -160,7 +160,11 @@ where .service .combine_batches(&owner.lastfm_username, spotify_account_id, batch_ids) .await?; - Ok(self.with_library_metadata(self.service.page(batch_id, &artist, &album).await)) + Ok(self.with_library_metadata( + self.service + .page_for_work(batch_id, &artist, &album) + .await?, + )) } pub(super) async fn rename_batch(&self, key: ReviewBatchKey, name: &str) -> Result<(), String> { @@ -584,10 +588,10 @@ where drop(membership); let session_id = self .service - .snapshot() + .owner_phase() .await - .ok_or_else(|| "No Last.fm import session is active.".to_string())? - .cache_id; + .map(|owner| owner.cache_id) + .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let view = self .service .retry_failed_apply( diff --git a/apps/desktop/src-tauri/src/lastfm_import/apply.rs b/apps/desktop/src-tauri/src/lastfm_import/apply.rs index 8ad9e19..9f74f82 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/apply.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/apply.rs @@ -655,7 +655,7 @@ impl Service { } pub(super) fn claim_apply_runner(&self) -> Option { - super::RunnerGuard::claim(&self.apply_running) + super::RunnerGuard::claim(&self.apply_running, &self.self_weak) } } @@ -668,7 +668,7 @@ pub(super) async fn apply_page( archive_remainder: bool, options: PageOptions, ) -> Result { - let (session, sync) = service.snapshot_with_sync().await; + let (session, sync) = service.snapshot_with_sync_for_work().await?; let Some(session) = session else { return Err("No Last.fm import session is active.".into()); }; @@ -894,15 +894,12 @@ fn cached_collection_tracks_for_apply( pub(super) type ApplyEffectFuture = Pin> + Send>>; -pub(super) async fn run_apply_upstream_effect< - T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, ->( +pub(super) async fn run_apply_upstream_effect( service: &Service, membership: &mut crate::spotify_membership::SpotifyMembershipGuard, library_owner: &crate::library_state::LibraryOwner, cooldown_store: &crate::store::FsCooldownStore, - provider: &retune_spotify::client::SpotifyClient, + provider: &retune_spotify::client::SpotifyClient, plan: &ApplyPlan, added_at: u64, ) -> Result<(), ApplyFailure> { @@ -926,10 +923,12 @@ pub(super) async fn run_apply_upstream_effect< } ApplyMembership::Tracks(uris) => { let cached_tracks = service - .snapshot() - .await + .snapshot_for_work() + .await? .as_ref() - .map(|session| cached_collection_tracks_for_apply(session, plan, added_at)) + .map(|snapshot| { + cached_collection_tracks_for_apply(&snapshot.session, plan, added_at) + }) .unwrap_or_default(); crate::spotify_membership::save_tracks_locked( provider, @@ -963,10 +962,12 @@ pub(super) async fn run_apply_upstream_effect< } if !tracks.is_empty() { let cached_tracks = service - .snapshot() - .await + .snapshot_for_work() + .await? .as_ref() - .map(|session| cached_collection_tracks_for_apply(session, plan, added_at)) + .map(|snapshot| { + cached_collection_tracks_for_apply(&snapshot.session, plan, added_at) + }) .unwrap_or_default(); crate::spotify_membership::save_tracks_locked( provider, diff --git a/apps/desktop/src-tauri/src/lastfm_import/collection.rs b/apps/desktop/src-tauri/src/lastfm_import/collection.rs index a3f6368..f1ccb93 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/collection.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/collection.rs @@ -1,11 +1,8 @@ use super::*; #[cfg(test)] -pub(super) async fn album_candidates< - T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, ->( - provider: &retune_spotify::client::SpotifyClient, +pub(super) async fn album_candidates( + provider: &retune_spotify::client::SpotifyClient, query: &str, source_album: Option<&str>, source_artist: Option<&str>, @@ -22,11 +19,8 @@ pub(super) async fn album_candidates< .map(|(candidates, _)| candidates) } -pub(super) async fn album_candidates_with_source< - T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, ->( - provider: &retune_spotify::client::SpotifyClient, +pub(super) async fn album_candidates_with_source( + provider: &retune_spotify::client::SpotifyClient, query: &str, source_album: Option<&str>, source_artist: Option<&str>, @@ -65,13 +59,12 @@ pub(super) async fn album_candidates_with_source< Ok((candidates, source)) } -pub(super) async fn fetch_complete_collection_album( - provider: &retune_spotify::client::SpotifyClient, +pub(super) async fn fetch_complete_collection_album( + provider: &retune_spotify::client::SpotifyClient, uri: &str, ) -> Result where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let album = provider .album(crate::provider::spotify_id(uri, "album")?) @@ -104,11 +97,11 @@ pub(super) struct ReviewBatchKey { } #[allow(clippy::too_many_arguments)] -pub(super) async fn current_account_binding( +pub(super) async fn current_account_binding( service: &Service, lastfm: &crate::lastfm::Service, membership_guard: &crate::spotify_membership::SpotifyMembershipGuard, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, require_provider: bool, require_spotify_binding: bool, @@ -116,13 +109,12 @@ pub(super) async fn current_account_binding( ) -> Result< ( AccountBinding, - Option>>, + Option>>, ), String, > where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { if !connection_state()? { return Err("Connect Spotify before importing its library.".into()); @@ -152,11 +144,11 @@ where })? .to_owned() }; - let Some(session) = service.snapshot().await else { + let Some(owner) = service.owner_phase().await else { return Err("No Last.fm import session is active.".into()); }; - if !session_account_matches( - &session, + if !owner_account_matches( + &owner, &lastfm_username, &spotify_account_id, require_spotify_binding, @@ -167,7 +159,7 @@ where .into(), ); } - if session.phase == ImportPhase::Suspended && !allow_suspended { + if owner.phase == ImportPhase::Suspended && !allow_suspended { return Err("The Last.fm import is suspended for account safety.".into()); } Ok(( @@ -180,19 +172,18 @@ where } #[allow(clippy::too_many_arguments)] -pub(super) async fn review_import( +pub(super) async fn review_import( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, key: ReviewBatchKey, action: ReviewAction, ) -> Result where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { ensure_review_mutable(service).await?; let membership_guard = spotify_membership.lock().await; @@ -231,18 +222,17 @@ where current_import_view(service, lastfm).await } -pub(super) async fn update_import_options( +pub(super) async fn update_import_options( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, key: ReviewBatchKey, options: PageOptions, ) -> Result where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { ensure_review_mutable(service).await?; let membership_guard = spotify_membership.lock().await; @@ -272,18 +262,17 @@ where current_import_view(service, lastfm).await } -pub(super) async fn update_import_count_mode( +pub(super) async fn update_import_count_mode( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, target_uri: &str, mode: CountMode, ) -> Result where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { ensure_review_mutable(service).await?; let membership_guard = spotify_membership.lock().await; @@ -311,17 +300,16 @@ where current_import_view(service, lastfm).await } -pub(super) async fn update_import_search_terms( +pub(super) async fn update_import_search_terms( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, show: bool, ) -> Result where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let membership_guard = spotify_membership.lock().await; let binding = current_account_binding( @@ -343,18 +331,17 @@ where current_import_view(service, lastfm).await } -pub(super) async fn select_import_matches( +pub(super) async fn select_import_matches( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, batch_id: u32, selections: &[(String, String)], ) -> Result<(Option, ImportStateView), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { ensure_review_mutable(service).await?; let membership_guard = spotify_membership.lock().await; @@ -378,7 +365,7 @@ where selections, ) .await?; - let page = service.page(batch_id, &artist, &album).await; + let page = service.page_for_work(batch_id, &artist, &album).await?; drop(membership_guard); let view = current_import_view(service, lastfm).await?; Ok((page, view)) @@ -433,12 +420,12 @@ pub(super) fn spotify_share_uri( } #[allow(clippy::too_many_arguments)] -pub(super) async fn load_collection_album_candidate( +pub(super) async fn load_collection_album_candidate( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, batch_id: u32, artist: &str, @@ -446,7 +433,6 @@ pub(super) async fn load_collection_album_candidate( ) -> Result<(AccountBinding, Option), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let (initial, resolved_provider) = { let guard = spotify_membership.lock().await; @@ -462,8 +448,8 @@ where ) .await?; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; requested_collection_batch(&session, batch_id, artist)?; if session @@ -504,8 +490,8 @@ where return Err("The connected Spotify account changed while loading the album; the import is suspended for safety.".into()); } let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; requested_collection_batch(&session, batch_id, artist)?; let candidate = collection_album_candidate( @@ -517,12 +503,12 @@ where } #[allow(clippy::too_many_arguments)] -pub(super) async fn search_collection_albums_with_source( +pub(super) async fn search_collection_albums_with_source( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, batch_id: u32, artist: &str, @@ -536,7 +522,6 @@ pub(super) async fn search_collection_albums_with_source( > where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { ensure_review_mutable(service).await?; let query = query.trim(); @@ -559,8 +544,8 @@ where ) .await?; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; requested_collection_batch(&session, batch_id, artist)?; drop(guard); @@ -600,8 +585,8 @@ where return Err("The connected Spotify account changed while searching; the import is suspended for safety.".into()); } let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; requested_collection_batch(&session, batch_id, artist)?; let membership = collection_membership_from(library, spotify_membership); @@ -620,12 +605,12 @@ where } #[allow(clippy::too_many_arguments)] -pub(super) async fn preview_or_add_collection_album( +pub(super) async fn preview_or_add_collection_album( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, batch_id: u32, artist: &str, @@ -634,7 +619,6 @@ pub(super) async fn preview_or_add_collection_album( ) -> Result<(Option, ImportStateView), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { if !valid_collection_album_uri(uri) { return Err("Choose a valid Spotify album URI.".into()); @@ -703,23 +687,25 @@ where .await?; } let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let (_, actual_album) = requested_collection_batch_with_album(&session, batch_id, artist)?; - let page = service.page(batch_id, artist, &actual_album).await; + let page = service + .page_for_work(batch_id, artist, &actual_album) + .await?; let view = service.state().await; drop(guard); Ok((page, view)) } #[allow(clippy::too_many_arguments)] -pub(super) async fn remove_collection_album( +pub(super) async fn remove_collection_album( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, batch_id: u32, artist: &str, @@ -727,7 +713,6 @@ pub(super) async fn remove_collection_album( ) -> Result<(Option, ImportStateView), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { if !valid_collection_album_uri(uri) { return Err("Choose a valid Spotify album URI.".into()); @@ -762,22 +747,24 @@ where ) .await?; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let (_, actual_album) = requested_collection_batch_with_album(&session, batch_id, artist)?; - let page = service.page(batch_id, artist, &actual_album).await; + let page = service + .page_for_work(batch_id, artist, &actual_album) + .await?; let view = service.state().await; drop(guard); Ok((page, view)) } #[allow(clippy::too_many_arguments)] -pub(super) async fn set_collection_album_import( +pub(super) async fn set_collection_album_import( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, batch_id: u32, artist: &str, @@ -786,7 +773,6 @@ pub(super) async fn set_collection_album_import( ) -> Result<(Option, ImportStateView), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { if !valid_collection_album_uri(uri) { return Err("Choose a valid Spotify album URI.".into()); @@ -816,28 +802,29 @@ where ) .await?; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let (_, actual_album) = requested_collection_batch_with_album(&session, batch_id, artist)?; - let page = service.page(batch_id, artist, &actual_album).await; + let page = service + .page_for_work(batch_id, artist, &actual_album) + .await?; let view = service.state().await; drop(guard); Ok((page, view)) } -pub(super) async fn activate_collection( +pub(super) async fn activate_collection( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, key: ReviewBatchKey, ) -> Result<(Option, ImportStateView), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { ensure_review_mutable(service).await?; let guard = spotify_membership.lock().await; @@ -868,19 +855,21 @@ where &mappings, ) .await?; - let page = service.page(key.batch_id, &key.artist, &actual_album).await; + let page = service + .page_for_work(key.batch_id, &key.artist, &actual_album) + .await?; drop(guard); let view = current_import_view(service, lastfm).await?; Ok((page, view)) } #[allow(clippy::too_many_arguments)] -pub(super) async fn change_import_track_with_source( +pub(super) async fn change_import_track_with_source( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, batch_id: u32, id: &str, @@ -894,7 +883,6 @@ pub(super) async fn change_import_track_with_source( > where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { ensure_review_mutable(service).await?; let (membership, resolved_provider) = { @@ -918,8 +906,8 @@ where ) }; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let row = session .rows @@ -1025,23 +1013,23 @@ where ) .await?; let page = service - .page( + .page_for_work( batch_id, &projection.representative_artist, &projection.representative_album, ) - .await; + .await?; drop(guard); let view = current_import_view(service, lastfm).await?; Ok(((page, view), source)) } #[allow(clippy::too_many_arguments)] -pub(super) async fn change_import_album_with_source( +pub(super) async fn change_import_album_with_source( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, batch_id: u32, id: &str, @@ -1049,7 +1037,6 @@ pub(super) async fn change_import_album_with_source( ) -> Result<(ImportStateView, retune_spotify::client::SearchSource), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { ensure_review_mutable(service).await?; let resolved_provider = { @@ -1069,8 +1056,8 @@ where resolved_provider.expect("required provider is resolved") }; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let row = session .rows diff --git a/apps/desktop/src-tauri/src/lastfm_import/commands.rs b/apps/desktop/src-tauri/src/lastfm_import/commands.rs index 16f3018..a348147 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/commands.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/commands.rs @@ -6,10 +6,9 @@ use super::model::{ApplyFailure, ImportApplyFinished}; use super::{ apply::{apply_failure_event, apply_work_pending, execute_apply_job}, current_import_view, enqueue_next_accept_all_job, lastfm_username, recover_before_apply_job, - startup_lastfm_identity_matches, startup_resume_plan, AcceptAllSummary, - CollectionAlbumCandidate, CountMode, ImportDefaults, ImportMatchSelection, ImportPageView, - ImportPhase, ImportQueuePage, ImportStateView, PageOptions, ReviewAction, ReviewApplyJob, - ReviewBatchKey, Service, LASTFM_QUEUE_PAGE_LIMIT, + AcceptAllSummary, CollectionAlbumCandidate, CountMode, ImportDefaults, ImportMatchSelection, + ImportPageView, ImportPhase, ImportQueuePage, ImportStateView, PageOptions, ReviewAction, + ReviewApplyJob, ReviewBatchKey, Service, LASTFM_QUEUE_PAGE_LIMIT, }; const IMPORT_WINDOWS: [&str; 2] = ["main", "lastfm-importer"]; @@ -70,18 +69,23 @@ pub(super) fn use_cases( pub(crate) async fn resume_persisted_import(app: tauri::AppHandle) { let state = app.state::(); let service = std::sync::Arc::clone(&state.lastfm_import); - let Some(session) = service.snapshot().await else { + let Some(owner) = service.owner_phase().await else { return; }; - let Some((username, _)) = startup_resume_plan(Some(&session)) else { + if !matches!( + owner.phase, + ImportPhase::Downloading | ImportPhase::Aggregating + ) { return; - }; - let live_username = if session.phase == ImportPhase::Aggregating { + } + let username = owner.lastfm_username; + let requires_identity_check = owner.phase == ImportPhase::Aggregating; + let live_username = if requires_identity_check { lastfm_username(state.lastfm.as_ref()).await.ok() } else { None }; - if !startup_lastfm_identity_matches(&session, live_username.as_deref()) { + if requires_identity_check && live_username.as_deref() != Some(username.as_str()) { if service.suspend_for_account_mismatch().await.is_ok() { let _ = emit_import_invalidated(&app); } @@ -249,12 +253,14 @@ pub(crate) async fn sync_lastfm_plays(app: tauri::AppHandle) -> Result Result<(), String> { + let service = std::sync::Arc::clone(&app.state::().lastfm_import); + service.set_importer_window_open(true); if let Some(window) = app.get_webview_window("lastfm-importer") { window.show().map_err(|error| error.to_string())?; window.set_focus().map_err(|error| error.to_string())?; return Ok(()); } - WebviewWindowBuilder::new( + let result = WebviewWindowBuilder::new( &app, "lastfm-importer", WebviewUrl::App("index.html".into()), @@ -262,9 +268,11 @@ pub(crate) async fn open_lastfm_importer(app: tauri::AppHandle) -> Result<(), St .title("Last.fm importer") .inner_size(1320.0, 840.0) .resizable(true) - .build() - .map(|_| ()) - .map_err(|error| error.to_string()) + .build(); + if result.is_err() && app.get_webview_window("lastfm-importer").is_none() { + service.set_importer_window_open(false); + } + result.map(|_| ()).map_err(|error| error.to_string()) } #[tauri::command] diff --git a/apps/desktop/src-tauri/src/lastfm_import/coordinator.rs b/apps/desktop/src-tauri/src/lastfm_import/coordinator.rs index 4ec84a0..f56dfa2 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/coordinator.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/coordinator.rs @@ -1,23 +1,23 @@ +use super::service::ImportOwnerPhase; use super::*; #[cfg(test)] -pub(super) async fn automatic_collection_album_seed( - provider: &retune_spotify::client::SpotifyClient, +pub(super) async fn automatic_collection_album_seed( + provider: &retune_spotify::client::SpotifyClient, artist: &str, album: &str, rows: &[SourceRow], ) -> Result<(Vec, Option), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { automatic_collection_album_seed_with_source(provider, artist, album, rows) .await .map(|(candidates, selected_uri, _)| (candidates, selected_uri)) } -pub(super) async fn automatic_collection_album_seed_with_source( - provider: &retune_spotify::client::SpotifyClient, +pub(super) async fn automatic_collection_album_seed_with_source( + provider: &retune_spotify::client::SpotifyClient, artist: &str, album: &str, rows: &[SourceRow], @@ -31,7 +31,6 @@ pub(super) async fn automatic_collection_album_seed_with_source( > where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let source_track_names = rows.iter().map(|row| row.track.clone()).collect::>(); let (mut candidates, source) = album_candidates_with_source( @@ -56,8 +55,8 @@ where } #[cfg(test)] -pub(super) async fn match_batch( - provider: &retune_spotify::client::SpotifyClient, +pub(super) async fn match_batch( + provider: &retune_spotify::client::SpotifyClient, artist: &str, album: &str, collection_shaped: bool, @@ -65,15 +64,14 @@ pub(super) async fn match_batch( ) -> Result, String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { match_batch_with_source(provider, artist, album, collection_shaped, rows) .await .map(|(matches, _)| matches) } -pub(super) async fn match_batch_with_source( - provider: &retune_spotify::client::SpotifyClient, +pub(super) async fn match_batch_with_source( + provider: &retune_spotify::client::SpotifyClient, artist: &str, album: &str, collection_shaped: bool, @@ -81,7 +79,6 @@ pub(super) async fn match_batch_with_source( ) -> Result<(Vec, retune_spotify::client::SearchSource), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { if album.is_empty() || collection_shaped { return Ok((Vec::new(), retune_spotify::client::SearchSource::Cache)); @@ -114,23 +111,22 @@ where )) } -pub(super) async fn current_matching_account( +pub(super) async fn current_matching_account( service: &Service, lastfm: &crate::lastfm::Service, membership_guard: &crate::spotify_membership::SpotifyMembershipGuard, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: impl FnOnce() -> Result, require_provider: bool, ) -> Result< ( AccountBinding, - Option>>, + Option>>, ), String, > where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let current = current_account_binding( service, @@ -152,14 +148,14 @@ where Ok(current) } -pub(super) fn session_account_matches( - session: &LastFmImportSessionV2, +pub(super) fn owner_account_matches( + owner: &ImportOwnerPhase, username: &str, spotify_account_id: &str, require_spotify_binding: bool, ) -> bool { - session.lastfm_username == username - && session + owner.lastfm_username == username + && owner .spotify_account_id .as_deref() .map_or(!require_spotify_binding, |bound| { @@ -203,18 +199,17 @@ pub(super) fn cached_spotify_identity_matches( } #[cfg(test)] -pub(super) async fn lazy_match_page_with_search( +pub(super) async fn lazy_match_page_with_search( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, key: &ReviewBatchKey, search: F, ) -> Result, String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, F: FnOnce(Vec) -> FFut, FFut: Future, String>>, { @@ -235,30 +230,29 @@ where .map(|(page, _)| page) } -pub(super) async fn lazy_match_page_with_search_source( +pub(super) async fn lazy_match_page_with_search_source( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, key: &ReviewBatchKey, search: F, ) -> Result<(Option, retune_spotify::client::SearchSource), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, F: FnOnce(Vec) -> FFut, FFut: Future, retune_spotify::client::SearchSource), String>>, { let batch_id = key.batch_id; let artist = key.artist.as_str(); let album = key.album.as_str(); - let Some(page) = service.page(batch_id, artist, album).await else { + let Some(page) = service.page_for_work(batch_id, artist, album).await? else { return Ok((None, retune_spotify::client::SearchSource::Cache)); }; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; if batch_match_plan(&session, Some((batch_id, artist, album))).is_empty() { let membership_guard = spotify_membership.lock().await; @@ -276,12 +270,12 @@ where // ponytail: one importer-wide lock; use per-batch locks only if throughput requires it. let _match_guard = service.lazy_match_lock.lock().await; - let Some(page) = service.page(batch_id, artist, album).await else { + let Some(page) = service.page_for_work(batch_id, artist, album).await? else { return Ok((None, retune_spotify::client::SearchSource::Cache)); }; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; if batch_match_plan(&session, Some((batch_id, artist, album))).is_empty() { let membership_guard = spotify_membership.lock().await; @@ -331,8 +325,8 @@ where let (results, source) = search(rows).await?; let results = if album.is_empty() { let current_session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; results .into_iter() @@ -383,23 +377,25 @@ where Some(default_count_mode), ) .await?; - Ok((service.page(batch_id, artist, album).await, source)) + Ok(( + service.page_for_work(batch_id, artist, album).await?, + source, + )) } #[allow(clippy::too_many_arguments)] -pub(super) async fn lazy_seed_collection_page( +pub(super) async fn lazy_seed_collection_page( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, cooldown_store: &crate::store::FsCooldownStore, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, key: &ReviewBatchKey, ) -> Result<(Option, bool), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let batch_id = key.batch_id; let artist = key.artist.as_str(); @@ -407,8 +403,8 @@ where // ponytail: one importer-wide lock; use per-batch locks only if throughput requires it. let _match_guard = service.lazy_match_lock.lock().await; let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; let Some(rows) = collection_album_seed_rows(&session, batch_id, artist, album) else { let membership_guard = spotify_membership.lock().await; @@ -421,7 +417,7 @@ where false, ) .await?; - return Ok((service.page(batch_id, artist, album).await, false)); + return Ok((service.page_for_work(batch_id, artist, album).await?, false)); }; let (initial_account, resolved_provider) = { let membership_guard = spotify_membership.lock().await; @@ -484,35 +480,38 @@ where ) .await?; Ok(( - service.page(batch_id, artist, album).await, + service.page_for_work(batch_id, artist, album).await?, source == retune_spotify::client::SearchSource::Network, )) } #[allow(clippy::too_many_arguments)] -pub(super) async fn lazy_match_page( +pub(super) async fn lazy_match_page( service: &Service, lastfm: &crate::lastfm::Service, spotify_membership: &crate::spotify_membership::SpotifyMembership, library: &crate::library_state::LibraryState, cooldown_store: &crate::store::FsCooldownStore, - provider: &impl Fn() -> Result>, String>, + provider: &impl Fn() -> Result>, String>, connection_state: &impl Fn() -> Result, key: ReviewBatchKey, ) -> Result<(Option, bool, bool), String> where T: retune_spotify::client::Transport, - S: retune_spotify::tokens::TokenStore, { let batch_id = key.batch_id; let artist = key.artist.as_str(); let album = key.album.as_str(); - if service.page(batch_id, artist, album).await.is_none() { + if service + .page_for_work(batch_id, artist, album) + .await? + .is_none() + { return Ok((None, false, false)); } let initial_session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; if collection_album_seed_rows(&initial_session, batch_id, artist, album).is_some() { let (page, network_search) = lazy_seed_collection_page( @@ -531,8 +530,8 @@ where } let initial_collection_shaped = batch_is_collection_shaped_for_id(&initial_session, batch_id); let session = service - .snapshot() - .await + .snapshot_for_work() + .await? .ok_or_else(|| "No Last.fm import session is active.".to_string())?; if batch_match_plan(&session, Some((batch_id, artist, album))).is_empty() { if cached_spotify_binding_is_current(service, lastfm, spotify_membership).await? @@ -564,9 +563,17 @@ where .rerank_collection_batch(batch_id, &membership, &mappings) .await?; } - return Ok((service.page(batch_id, artist, album).await, false, false)); + return Ok(( + service.page_for_work(batch_id, artist, album).await?, + false, + false, + )); } - return Ok((service.page(batch_id, artist, album).await, false, false)); + return Ok(( + service.page_for_work(batch_id, artist, album).await?, + false, + false, + )); } let (page, source) = lazy_match_page_with_search_source( service, diff --git a/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs b/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs index defa463..7c2d9e1 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/integration_tests.rs @@ -1,4 +1,5 @@ use super::model::ImportApplyFinished; +use super::service::ImportOwnerPhase; use super::*; use crate::{ spotify_membership::SpotifyMembership, @@ -17,6 +18,30 @@ use std::{ time::Duration, }; +#[cfg(target_os = "macos")] +#[path = "memory_experiment.rs"] +mod memory_experiment; + +struct SaveHookReleaseOnDrop(Option>); + +impl SaveHookReleaseOnDrop { + fn new(hook: Arc) -> Self { + Self(Some(hook)) + } + + fn release(&mut self) { + if let Some(hook) = self.0.take() { + hook.release(); + } + } +} + +impl Drop for SaveHookReleaseOnDrop { + fn drop(&mut self) { + self.release(); + } +} + // Opt-in synthetic evidence for docs/lastfm-interaction-audit.md. #[tokio::test] #[ignore = "local acceptance benchmark; set RETUNE_APPLY_FIXTURE to a copied session JSON"] @@ -72,7 +97,7 @@ async fn audit_apply_acceptance_cost() { for sample in 0..3 { let directory = tempfile::tempdir().unwrap(); let service = Service::new(directory.path()); - *service.session.lock().await = Some(session.clone()); + service.install_session_for_test(session.clone()).await; let start = Instant::now(); super::apply::apply_page( &service, @@ -1715,6 +1740,17 @@ async fn count_mode_use_case_persists_session_and_reusable_mapping_default() { .unwrap(); assert_eq!(view.phase, Some(ImportPhase::Review)); + assert_eq!( + service.snapshot().await.unwrap().default_count_mode, + CountMode::Overwrite + ); + assert_eq!( + service.export_mappings().await.mappings.default_count_mode, + CountMode::Overwrite + ); + tokio::time::timeout(Duration::from_secs(5), service.wait_for_queued_writes()) + .await + .expect("queued review save should finish"); let reloaded = Service::new(directory.path()); assert_eq!( reloaded.snapshot().await.unwrap().default_count_mode, @@ -1944,10 +1980,21 @@ async fn aborted_import_session_save_finishes_disk_and_memory_publication() { tokio::task::yield_now().await; } hook.wait_until_reached(); - assert!(!service.snapshot().await.unwrap().search_terms); + let mut hook_release = SaveHookReleaseOnDrop::new(Arc::clone(&hook)); + let reads = tokio::time::timeout(Duration::from_millis(250), async { + let snapshot = service.snapshot().await.unwrap(); + let page = service.page_for_work(1, "Artist", "Album").await?; + let queue = service.queue_page(0, 1).await?; + Ok::<_, String>((snapshot, page, queue)) + }) + .await; + hook_release.release(); + let (snapshot, _page, _queue) = reads + .expect("resident snapshot, page, and queue reads should bypass blocked disk writes") + .expect("resident snapshot, page, and queue reads should succeed"); + assert!(!snapshot.search_terms); mutation.abort(); - hook.release(); assert!(mutation.await.unwrap_err().is_cancelled()); tokio::time::timeout(Duration::from_secs(1), async { while !service.snapshot().await.unwrap().search_terms { @@ -3030,30 +3077,35 @@ fn cached_spotify_identity_only_trusts_an_exact_matching_cache() { } #[test] -fn session_account_matching_requires_bound_identity_for_owned_mutations() { - let mut session = LastFmImportSessionV2::new("lastfm-user".into(), "spotify-a".into(), 1); - assert!(session_account_matches( - &session, +fn owner_account_matching_requires_bound_identity_for_owned_mutations() { + let mut owner = ImportOwnerPhase { + cache_id: "cache".into(), + lastfm_username: "lastfm-user".into(), + spotify_account_id: Some("spotify-a".into()), + phase: ImportPhase::Review, + }; + assert!(owner_account_matches( + &owner, "lastfm-user", "spotify-a", true )); - assert!(!session_account_matches( - &session, + assert!(!owner_account_matches( + &owner, "lastfm-user", "spotify-b", true )); - session.spotify_account_id = None; - assert!(!session_account_matches( - &session, + owner.spotify_account_id = None; + assert!(!owner_account_matches( + &owner, "lastfm-user", "spotify-a", true )); - assert!(session_account_matches( - &session, + assert!(owner_account_matches( + &owner, "lastfm-user", "spotify-a", false @@ -4221,7 +4273,7 @@ async fn large_queue_follows_every_cursor_in_order_without_materializing_prior_s album_labels: Vec::new(), }) .collect(); - *service.session.lock().await = Some(session); + service.install_session_for_test(session).await; let mut cursor = 0; let mut seen_pages = Vec::with_capacity(count as usize); @@ -7491,7 +7543,7 @@ async fn page_projects_count_modes_to_visible_fuzzy_targets() { assert_eq!( page.fuzzy_groups[&visible_target] .iter() - .map(|row| row.stable_id.clone()) + .cloned() .collect::>(), visible_ids.iter().cloned().collect::>() ); @@ -7676,7 +7728,7 @@ async fn interrupted_count_mode_transaction_rolls_forward_both_files_on_reload() } #[tokio::test(flavor = "current_thread")] -async fn aborted_journal_recovery_publishes_before_the_next_mutation() { +async fn failed_review_save_latches_until_restart_and_recovery_cancellation_is_safe() { let dir = tempfile::tempdir().unwrap(); let service = Service::new(dir.path()); let mut session = LastFmImportSessionV2::new("user".into(), "spotify".into(), 10); @@ -7704,38 +7756,73 @@ async fn aborted_journal_recovery_publishes_before_the_next_mutation() { failed_save.release(); assert!(transaction.await.unwrap().is_err()); + let blocked = service + .set_search_terms("user", "spotify", true) + .await + .unwrap_err(); + assert!(blocked.contains("recovery")); + + let reloaded = Service::new(dir.path()); + assert_eq!( + reloaded.snapshot().await.unwrap().default_count_mode, + CountMode::Zero + ); + assert_eq!( + reloaded + .mappings_for("user", Some("spotify")) + .await + .unwrap() + .default_count_mode, + CountMode::Zero + ); + + let mut pending_session = reloaded.snapshot().await.unwrap(); + pending_session.default_count_mode = CountMode::Overwrite; + let mut pending_mappings = reloaded.export_mappings().await; + pending_mappings.mappings.default_count_mode = CountMode::Overwrite; + reloaded + .review_transaction_store + .save(&super::store::ReviewTransaction::new( + Some(pending_session), + pending_mappings, + )) + .unwrap(); let recovery_hook = crate::store::SaveHook::new(false); - service + reloaded .review_transaction_store .arm_recovery(Arc::clone(&recovery_hook)); let cancelled = { - let service = Arc::clone(&service); - tokio::spawn(async move { service.set_search_terms("user", "spotify", true).await }) + let reloaded = Arc::clone(&reloaded); + tokio::spawn(async move { reloaded.set_search_terms("user", "spotify", true).await }) }; - while !recovery_hook.is_reached() { - tokio::task::yield_now().await; - } + tokio::time::timeout(Duration::from_secs(5), async { + while !recovery_hook.is_reached() { + tokio::task::yield_now().await; + } + }) + .await + .expect("recovery should reach its test hook"); recovery_hook.wait_until_reached(); cancelled.abort(); recovery_hook.release(); assert!(cancelled.await.unwrap_err().is_cancelled()); - service + reloaded .set_search_terms("user", "spotify", true) .await .unwrap(); assert_eq!( - service.snapshot().await.unwrap().default_count_mode, - CountMode::Zero + reloaded.snapshot().await.unwrap().default_count_mode, + CountMode::Overwrite ); assert_eq!( - service.export_mappings().await.mappings.default_count_mode, - CountMode::Zero + reloaded.export_mappings().await.mappings.default_count_mode, + CountMode::Overwrite ); - let reloaded = Service::new(dir.path()); - let session = reloaded.snapshot().await.unwrap(); + let persisted = Service::new(dir.path()); + let session = persisted.snapshot().await.unwrap(); assert!(session.search_terms); - assert_eq!(session.default_count_mode, CountMode::Zero); + assert_eq!(session.default_count_mode, CountMode::Overwrite); } #[tokio::test(flavor = "current_thread")] @@ -9618,18 +9705,33 @@ async fn overlapping_mutations_preserve_memory_and_disk_changes() { #[tokio::test] async fn failed_blocking_persistence_does_not_commit_live_mutation() { let dir = tempfile::tempdir().unwrap(); - let mut service = Service::new(dir.path()); + let service = Service::new(dir.path()); start_bound(&service, "lastfm-user", "spotify-user", 500).await; let mut review_session = service.snapshot().await.unwrap(); review_session.phase = ImportPhase::Review; service.save(review_session).await.unwrap(); - Arc::get_mut(&mut service).unwrap().store.path = dir.path().to_path_buf(); + let hook = crate::store::SaveHook::new(true); + service.store.arm_save(Arc::clone(&hook)); + let mutation = { + let service = Arc::clone(&service); + tokio::spawn(async move { + service + .set_search_terms("lastfm-user", "spotify-user", false) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(5), async { + while !hook.is_reached() { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocking save should reach its failure hook"); + hook.wait_until_reached(); + hook.release(); - let error = service - .set_search_terms("lastfm-user", "spotify-user", false) - .await - .unwrap_err(); - assert!(error.contains("Could not save the Last.fm import session.")); + let error = mutation.await.unwrap().unwrap_err(); + assert!(error.contains("injected save failure")); assert!(service.snapshot().await.unwrap().search_terms); assert!( ImportSessionStore::new(dir.path()) @@ -11034,7 +11136,7 @@ async fn cancelled_and_panicked_runner_guards_release_their_claims() { let started = Arc::new(tokio::sync::Notify::new()); let task_started = Arc::clone(&started); let task = tokio::spawn(async move { - let _run = RunnerGuard::claim(&task_running).unwrap(); + let _run = RunnerGuard::claim(&task_running, &std::sync::Weak::new()).unwrap(); task_started.notify_one(); if panic { panic!("runner panic"); @@ -11048,7 +11150,7 @@ async fn cancelled_and_panicked_runner_guards_release_their_claims() { task.abort(); assert!(task.await.unwrap_err().is_cancelled()); } - assert!(RunnerGuard::claim(&running).is_some()); + assert!(RunnerGuard::claim(&running, &std::sync::Weak::new()).is_some()); } } diff --git a/apps/desktop/src-tauri/src/lastfm_import/memory_experiment.rs b/apps/desktop/src-tauri/src/lastfm_import/memory_experiment.rs new file mode 100644 index 0000000..3566b7d --- /dev/null +++ b/apps/desktop/src-tauri/src/lastfm_import/memory_experiment.rs @@ -0,0 +1,168 @@ +use std::{ + env, fs, + path::PathBuf, + time::{Duration, Instant}, +}; + +use std::process::Command; + +use super::super::Service; + +const FIXTURE_ENV: &str = "RETUNE_LASTFM_MEMORY_FIXTURE"; +const RELEASE_CYCLES: usize = 3; + +#[tokio::test] +#[ignore = "manual macOS process-memory experiment; set RETUNE_LASTFM_MEMORY_FIXTURE to a copied session JSON"] +async fn real_session_park_reload_memory_experiment() { + let fixture = env::var_os(FIXTURE_ENV) + .map(PathBuf::from) + .expect("RETUNE_LASTFM_MEMORY_FIXTURE must point to a copied session JSON"); + let fixture_bytes = fs::metadata(&fixture) + .expect("could not inspect the copied Last.fm session") + .len(); + let app_data = tempfile::tempdir().expect("could not create isolated app data"); + let isolated_session = app_data.path().join("lastfm-import.json"); + fs::copy(&fixture, &isolated_session) + .expect("could not copy Last.fm session into temp app data"); + assert_eq!( + fs::metadata(&isolated_session) + .expect("could not inspect isolated Last.fm session") + .len(), + fixture_bytes, + "isolated session copy has a different size" + ); + + let (baseline_footprint_bytes, baseline_peak_bytes) = process_footprint_bytes(); + println!( + "memory_experiment stage=baseline cycle=0 fixture_bytes={} footprint_bytes={} peak_bytes={}", + fixture_bytes, baseline_footprint_bytes, baseline_peak_bytes, + ); + + let load_started = Instant::now(); + let service = Service::new(app_data.path()); + let initial_load_ms = load_started.elapsed().as_millis(); + let initial = service.state().await; + assert!(initial.phase.is_some(), "the isolated fixture did not load"); + let summary_counts = |view: &super::super::ImportStateView| { + ( + view.total_scrobbles, + view.included_scrobbles, + view.processed_scrobbles, + view.matched_scrobbles, + view.remaining, + view.pending_review, + ) + }; + let expected_counts = summary_counts(&initial); + let (footprint_bytes, peak_bytes) = process_footprint_bytes(); + println!( + "memory_experiment pid={} stage=resident cycle=0 fixture_bytes={} load_ms={} total_scrobbles={} included_scrobbles={} remaining={} footprint_bytes={} peak_bytes={}", + std::process::id(), + fixture_bytes, + initial_load_ms, + initial.total_scrobbles, + initial.included_scrobbles, + initial.remaining, + footprint_bytes, + peak_bytes, + ); + + for cycle in 1..=RELEASE_CYCLES { + let park_started = Instant::now(); + service + .close_importer_window_and_park() + .await + .expect("could not park isolated Last.fm session"); + let parked_at = Instant::now(); + let park_ms = park_started.elapsed().as_millis(); + let parked = service.state().await; + assert!(parked.phase.is_some(), "parking lost the session summary"); + assert_eq!( + summary_counts(&parked), + expected_counts, + "parked summary counts changed in cycle {cycle}" + ); + let (immediate_footprint_bytes, immediate_peak_bytes) = process_footprint_bytes(); + let immediate_after_park_ms = parked_at.elapsed().as_millis(); + println!( + "memory_experiment stage=parked_immediate cycle={} park_ms={} immediate_after_park_ms={} total_scrobbles={} included_scrobbles={} remaining={} footprint_bytes={} peak_bytes={}", + cycle, + park_ms, + immediate_after_park_ms, + parked.total_scrobbles, + parked.included_scrobbles, + parked.remaining, + immediate_footprint_bytes, + immediate_peak_bytes, + ); + tokio::time::sleep_until( + tokio::time::Instant::from_std(parked_at) + Duration::from_millis(200), + ) + .await; + let (settled_footprint_bytes, settled_peak_bytes) = process_footprint_bytes(); + let settled_after_park_ms = parked_at.elapsed().as_millis(); + println!( + "memory_experiment stage=parked_settled cycle={} settled_after_park_ms={} footprint_bytes={} peak_bytes={}", + cycle, settled_after_park_ms, settled_footprint_bytes, settled_peak_bytes, + ); + + service.set_importer_window_open(true); + let reload_started = Instant::now(); + let queue = service + .queue_page(0, 1) + .await + .expect("could not reload the isolated Last.fm queue"); + let reopened = service.state().await; + assert_eq!( + summary_counts(&reopened), + expected_counts, + "reloaded summary counts changed in cycle {cycle}" + ); + let reload_ms = reload_started.elapsed().as_millis(); + let (footprint_bytes, peak_bytes) = process_footprint_bytes(); + println!( + "memory_experiment stage=resident cycle={} reload_ms={} queue_items={} queue_total={} total_scrobbles={} included_scrobbles={} footprint_bytes={} peak_bytes={}", + cycle, + reload_ms, + queue.items.len(), + queue.total, + reopened.total_scrobbles, + reopened.included_scrobbles, + footprint_bytes, + peak_bytes, + ); + } +} + +fn process_footprint_bytes() -> (u64, u64) { + let pid = std::process::id().to_string(); + let output = Command::new("/usr/bin/footprint") + .args(["-p", pid.as_str(), "-f", "bytes", "--noCategories"]) + .output() + .expect("could not run /usr/bin/footprint for the current test process"); + assert!( + output.status.success(), + "/usr/bin/footprint failed for the current test process" + ); + let output = String::from_utf8_lossy(&output.stdout); + ( + footprint_metric(&output, "phys_footprint"), + footprint_metric(&output, "phys_footprint_peak"), + ) +} + +fn footprint_metric(output: &str, metric: &str) -> u64 { + for line in output.lines() { + let Some((name, value)) = line.trim().split_once(':') else { + continue; + }; + if name == metric { + return value + .split_whitespace() + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("footprint did not return {metric} bytes")); + } + } + panic!("footprint omitted {metric}"); +} diff --git a/apps/desktop/src-tauri/src/lastfm_import/model.rs b/apps/desktop/src-tauri/src/lastfm_import/model.rs index be5f19c..e6bd957 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/model.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/model.rs @@ -535,7 +535,7 @@ pub(crate) struct ImportPageView { pub page_count: usize, pub rows: Vec, pub options: PageOptions, - pub fuzzy_groups: BTreeMap>, + pub fuzzy_groups: BTreeMap>, pub count_modes: BTreeMap, pub resolved_counts: BTreeMap, pub locked_count_modes: BTreeSet, diff --git a/apps/desktop/src-tauri/src/lastfm_import/review.rs b/apps/desktop/src-tauri/src/lastfm_import/review.rs index e823f61..00e793f 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/review.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/review.rs @@ -170,7 +170,6 @@ pub(super) fn page_view( }, }) .collect(); - let mut fuzzy_groups = BTreeMap::>::new(); let mut count_rows = BTreeMap::>::new(); for row in &rows { let decision = default_decision(session, &row.stable_id); @@ -192,12 +191,17 @@ pub(super) fn page_view( continue; }; count_rows.entry(target_uri.clone()).or_default().push(*row); - fuzzy_groups - .entry(target_uri) - .or_default() - .push((*row).clone()); } - fuzzy_groups.retain(|_, rows| rows.len() > 1 || rows.iter().any(|row| row.variants.len() > 1)); + let fuzzy_groups = count_rows + .iter() + .filter(|(_, rows)| rows.len() > 1 || rows.iter().any(|row| row.variants.len() > 1)) + .map(|(target, rows)| { + ( + target.clone(), + rows.iter().map(|row| row.stable_id.clone()).collect(), + ) + }) + .collect::>(); let visible_targets = fuzzy_groups.keys().cloned().collect::>(); let count_modes = visible_targets .iter() diff --git a/apps/desktop/src-tauri/src/lastfm_import/service.rs b/apps/desktop/src-tauri/src/lastfm_import/service.rs index 3dae97e..9079433 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/service.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/service.rs @@ -57,69 +57,111 @@ impl ImportOwnerPhase { } } +enum SessionResidency { + Unhydrated, + Resident(Option), + Parked { + owner: ImportOwnerPhase, + view: ImportStateView, + }, +} + +pub(super) struct SessionSnapshot { + pub(super) session: LastFmImportSessionV2, + _lease: SessionLease, +} + +impl std::ops::Deref for SessionSnapshot { + type Target = LastFmImportSessionV2; + + fn deref(&self) -> &Self::Target { + &self.session + } +} + +struct SessionLease { + service: std::sync::Weak, +} + pub(crate) struct Service { + pub(super) self_weak: std::sync::Weak, pub(super) store: ImportSessionStore, pub(super) incremental_store: IncrementalStore, pub(super) mappings_store: MappingsStore, pub(super) review_transaction_store: ReviewTransactionStore, - pub(super) session: Arc>>, + session: Arc>, pub(super) sync_state: Arc>, sync_mutation_gate: Arc>, pub(super) mappings: Arc>, persistence_gate: Arc>, - session_writes: Arc, - #[allow(dead_code)] - review_writes: Arc, + session_writes: Arc, + review_writes: Arc, pub(super) reconciliation_lock: Mutex<()>, pub(super) lazy_match_lock: Mutex<()>, pub(super) running: Arc, pub(super) apply_running: Arc, pub(super) sync_running: Arc, + importer_window_open: AtomicBool, + active_session_leases: std::sync::atomic::AtomicUsize, pub(super) restore_mutations: Arc, hydration: std::sync::atomic::AtomicU8, } -pub(super) struct RunnerGuard(Arc); - -// ponytail: one coalescing writer is enough for local importer metadata. -struct SessionWriteQueue { - pending: Arc>>, +pub(super) struct RunnerGuard { running: Arc, + service: std::sync::Weak, } -#[allow(dead_code)] -struct ReviewWriteQueue { - pending: Arc>>, +// ponytail: one coalescing writer is enough for local importer metadata. +struct DirtyWriteQueue { + pending: Arc>, running: Arc, } -impl ReviewWriteQueue { - fn new() -> Self { - Self { - pending: Arc::new(Mutex::new(None)), - running: Arc::new(AtomicBool::new(false)), - } +fn request_park_after_write(service: &std::sync::Weak) { + if let Some(service) = service.upgrade() { + service.schedule_park_if_closed(); } } -impl SessionWriteQueue { +impl DirtyWriteQueue { fn new() -> Self { Self { - pending: Arc::new(Mutex::new(None)), + pending: Arc::new(Mutex::new(false)), running: Arc::new(AtomicBool::new(false)), } } } impl RunnerGuard { - pub(super) fn claim(running: &Arc) -> Option { - (!running.swap(true, Ordering::AcqRel)).then(|| Self(Arc::clone(running))) + pub(super) fn claim( + running: &Arc, + service: &std::sync::Weak, + ) -> Option { + (!running.swap(true, Ordering::AcqRel)).then(|| Self { + running: Arc::clone(running), + service: service.clone(), + }) } } impl Drop for RunnerGuard { fn drop(&mut self) { - self.0.store(false, Ordering::Release); + self.running.store(false, Ordering::Release); + if let Some(service) = self.service.upgrade() { + service.schedule_park_if_closed(); + } + } +} + +impl Drop for SessionLease { + fn drop(&mut self) { + let Some(service) = self.service.upgrade() else { + return; + }; + if service.active_session_leases.fetch_sub(1, Ordering::AcqRel) == 1 { + service.schedule_park_if_closed(); + } } } @@ -134,10 +176,12 @@ impl Service { #[cfg(test)] pub(crate) fn new(app_data_dir: impl AsRef) -> Arc { - Self::new_with_restore_state( + let service = Self::new_with_restore_state( app_data_dir, Arc::new(crate::restore_latch::RestoreMutationState::default()), - ) + ); + service.set_importer_window_open(true); + service } #[cfg(test)] @@ -154,6 +198,9 @@ impl Service { .recover(&store, &incremental_store, &mappings_store) .map(drop) .expect("Last.fm review transaction recovery failed"); + review_transaction_store + .sync_parent() + .expect("Last.fm review transaction directory sync failed"); let mut load_problems = Vec::new(); let mut session = match store.load() { Ok(mut session) => { @@ -203,23 +250,26 @@ impl Service { None => problem, }); } - Arc::new(Self { + Arc::new_cyclic(|self_weak| Self { + self_weak: self_weak.clone(), store, incremental_store, mappings_store, review_transaction_store, - session: Arc::new(Mutex::new(session)), + session: Arc::new(Mutex::new(SessionResidency::Resident(session))), sync_state: Arc::new(Mutex::new(sync_state)), sync_mutation_gate: Arc::new(Mutex::new(())), mappings: Arc::new(Mutex::new(mappings)), persistence_gate: Arc::new(Mutex::new(())), - session_writes: Arc::new(SessionWriteQueue::new()), - review_writes: Arc::new(ReviewWriteQueue::new()), + session_writes: Arc::new(DirtyWriteQueue::new()), + review_writes: Arc::new(DirtyWriteQueue::new()), reconciliation_lock: Mutex::new(()), lazy_match_lock: Mutex::new(()), running: Arc::new(AtomicBool::new(false)), apply_running: Arc::new(AtomicBool::new(false)), sync_running: Arc::new(AtomicBool::new(false)), + importer_window_open: AtomicBool::new(false), + active_session_leases: std::sync::atomic::AtomicUsize::new(0), restore_mutations, hydration: std::sync::atomic::AtomicU8::new(1), }) @@ -230,12 +280,13 @@ impl Service { restore_mutations: Arc, ) -> Arc { let app_data_dir = app_data_dir.as_ref(); - Arc::new(Self { + Arc::new_cyclic(|self_weak| Self { + self_weak: self_weak.clone(), store: ImportSessionStore::new(app_data_dir), incremental_store: IncrementalStore::new(app_data_dir), mappings_store: MappingsStore::new(app_data_dir), review_transaction_store: ReviewTransactionStore::new(app_data_dir), - session: Arc::new(Mutex::new(None)), + session: Arc::new(Mutex::new(SessionResidency::Unhydrated)), sync_state: Arc::new(Mutex::new(LastFmSyncState { version: LASTFM_SYNC_VERSION, sync_problem: Some("Retune is still loading Last.fm import state.".into()), @@ -247,13 +298,15 @@ impl Service { ..PersistedLastFmMappings::default() })), persistence_gate: Arc::new(Mutex::new(())), - session_writes: Arc::new(SessionWriteQueue::new()), - review_writes: Arc::new(ReviewWriteQueue::new()), + session_writes: Arc::new(DirtyWriteQueue::new()), + review_writes: Arc::new(DirtyWriteQueue::new()), reconciliation_lock: Mutex::new(()), lazy_match_lock: Mutex::new(()), running: Arc::new(AtomicBool::new(false)), apply_running: Arc::new(AtomicBool::new(false)), sync_running: Arc::new(AtomicBool::new(false)), + importer_window_open: AtomicBool::new(false), + active_session_leases: std::sync::atomic::AtomicUsize::new(0), restore_mutations, hydration: std::sync::atomic::AtomicU8::new(0), }) @@ -269,6 +322,226 @@ impl Service { self.hydration.load(Ordering::Acquire) == 1 } + pub(crate) fn set_importer_window_open(&self, open: bool) { + self.importer_window_open.store(open, Ordering::Release); + if !open { + self.schedule_park_if_closed(); + } + } + + #[cfg(test)] + pub(super) async fn close_importer_window_and_park(&self) -> Result<(), String> { + self.importer_window_open.store(false, Ordering::Release); + self.park_if_closed().await + } + + fn schedule_park_if_closed(&self) { + if self.importer_window_open.load(Ordering::Acquire) { + return; + } + let Some(service) = self.self_weak.upgrade() else { + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = service.park_if_closed().await { + log::warn!(target: "lastfm_import", "Could not park idle Last.fm importer state: {error}"); + } + }); + } + + fn has_active_work(&self) -> bool { + self.running.load(Ordering::Acquire) + || self.apply_running.load(Ordering::Acquire) + || self.sync_running.load(Ordering::Acquire) + || self.active_session_leases.load(Ordering::Acquire) != 0 + } + + fn has_queued_writer(&self) -> bool { + self.session_writes.running.load(Ordering::Acquire) + || self.review_writes.running.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(super) async fn wait_for_queued_writes(&self) { + loop { + let session_pending = *self.session_writes.pending.lock().await; + let review_pending = *self.review_writes.pending.lock().await; + if !session_pending + && !review_pending + && !self.session_writes.running.load(Ordering::Acquire) + && !self.review_writes.running.load(Ordering::Acquire) + { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + pub(crate) async fn park_if_closed(&self) -> Result<(), String> { + if self.importer_window_open.load(Ordering::Acquire) + || self.has_active_work() + || self.has_queued_writer() + { + return Ok(()); + } + let persistence_gate = Arc::clone(&self.persistence_gate).lock_owned().await; + if self.importer_window_open.load(Ordering::Acquire) + || self.has_active_work() + || self.has_queued_writer() + { + drop(persistence_gate); + return Ok(()); + } + self.restore_mutations.ensure_allowed()?; + let session_pending = *self.session_writes.pending.lock().await; + let review_pending = *self.review_writes.pending.lock().await; + if session_pending + || review_pending + || self.session_writes.running.load(Ordering::Acquire) + || self.review_writes.running.load(Ordering::Acquire) + { + drop(persistence_gate); + return Ok(()); + } + let payload = { + let mut slot = self.session.lock().await; + // Resident readers acquire their leases under this same lock. Recheck after taking + // it so a read that passed the earlier atomic preflight cannot race this eviction. + if self.importer_window_open.load(Ordering::Acquire) + || self.has_active_work() + || self.has_queued_writer() + { + return Ok(()); + } + let SessionResidency::Resident(Some(session)) = &*slot else { + return Ok(()); + }; + let owner = ImportOwnerPhase { + cache_id: session.cache_id.clone(), + lastfm_username: session.lastfm_username.clone(), + spotify_account_id: session.spotify_account_id.clone(), + phase: session.phase, + }; + let sync = self.sync_state.lock().await; + let mut view = if session.phase == ImportPhase::Suspended { + suspended_state_view(session) + } else { + state_view(Some(session)) + }; + if matches!(session.phase, ImportPhase::Review | ImportPhase::Done) { + view.remaining = remaining_with_apply_queue(session, &sync.apply_queue); + } + view.syncing = self.sync_running.load(Ordering::Acquire); + view.last_synced_at = sync.last_synced_at; + view.pending_review = sync.backlog.len(); + view.sync_problem = sync.sync_problem.clone(); + view.applying_all = sync.accept_all.is_some(); + drop(sync); + let previous = std::mem::replace(&mut *slot, SessionResidency::Parked { owner, view }); + match previous { + SessionResidency::Resident(Some(session)) => session, + _ => unreachable!("resident Last.fm session changed while locked"), + } + }; + drop(persistence_gate); + tauri::async_runtime::spawn_blocking(move || { + drop(payload); + }) + .await + .map_err(|error| format!("Last.fm import session cleanup task stopped: {error}"))?; + Ok(()) + } + + async fn ensure_session_resident_under_gate(&self) -> Result<(), String> { + let parked_owner = { + let slot = self.session.lock().await; + match &*slot { + SessionResidency::Unhydrated => { + return Err("Retune is still loading Last.fm import state.".into()); + } + SessionResidency::Resident(_) => return Ok(()), + SessionResidency::Parked { owner, .. } => owner.clone(), + } + }; + let store = self.store.clone(); + let mut session = tauri::async_runtime::spawn_blocking(move || { + let mut session = store.load()?.ok_or_else(|| { + "The parked Last.fm import session is missing from disk.".to_string() + })?; + refresh_cached_album_matches(&mut session); + Ok::<_, String>(session) + }) + .await + .map_err(|error| format!("Last.fm import reload task stopped: {error}"))??; + if session.cache_id != parked_owner.cache_id + || session.lastfm_username != parked_owner.lastfm_username + || session.spotify_account_id != parked_owner.spotify_account_id + || session.phase != parked_owner.phase + { + return Err("The parked Last.fm import session identity changed on disk.".into()); + } + let sync = self.sync_state.lock().await.clone(); + if upgrade_legacy_pending_batches(&mut session, &sync.apply_queue) { + let store = self.store.clone(); + session = tauri::async_runtime::spawn_blocking(move || { + store.save(&session)?; + Ok::<_, String>(session) + }) + .await + .map_err(|error| format!("Last.fm import upgrade save task stopped: {error}"))??; + } + let mut slot = self.session.lock().await; + match &*slot { + SessionResidency::Parked { owner, .. } if owner == &parked_owner => { + *slot = SessionResidency::Resident(Some(session)); + Ok(()) + } + SessionResidency::Resident(_) => Ok(()), + _ => Err("Last.fm import residency changed while reloading its session.".into()), + } + } + + async fn lock_resident_session( + &self, + ) -> Result, String> { + self.ensure_hydrated()?; + loop { + let slot = self.session.lock().await; + if matches!(&*slot, SessionResidency::Resident(_)) { + return Ok(slot); + } + if matches!(&*slot, SessionResidency::Unhydrated) { + return Err("Retune is still loading Last.fm import state.".into()); + } + drop(slot); + + // Reads of resident state never wait for a persistence queue's disk I/O. Only a + // parked session needs the gate, so it cannot be reloaded from beneath a writer. + let persistence_gate = Arc::clone(&self.persistence_gate).lock_owned().await; + self.ensure_session_resident_under_gate().await?; + let slot = self.session.lock().await; + if matches!(&*slot, SessionResidency::Resident(_)) { + // Keep the slot locked across this handoff. Callers acquire their lease before + // releasing it, which prevents park_if_closed from evicting the just-reloaded + // payload in the gap between releasing the gate and acquiring a lease. + drop(persistence_gate); + return Ok(slot); + } + if matches!(&*slot, SessionResidency::Unhydrated) { + return Err("Retune is still loading Last.fm import state.".into()); + } + drop(slot); + drop(persistence_gate); + } + } + + fn session_lease(&self) -> SessionLease { + self.active_session_leases.fetch_add(1, Ordering::AcqRel); + SessionLease { + service: self.self_weak.clone(), + } + } + pub(crate) async fn hydrate(&self) -> Result<(), String> { let store = self.store.clone(); let incremental_store = self.incremental_store.clone(); @@ -278,6 +551,7 @@ impl Service { review_transaction_store .recover(&store, &incremental_store, &mappings_store) .map(drop)?; + review_transaction_store.sync_parent()?; let mut load_problems = Vec::new(); let mut session = match store.load() { Ok(mut session) => { @@ -330,10 +604,11 @@ impl Service { Ok::<_, String>((session, sync_state, mappings)) }) .await??; - *self.session.lock().await = session; + *self.session.lock().await = SessionResidency::Resident(session); *self.sync_state.lock().await = sync_state; *self.mappings.lock().await = mappings; self.hydration.store(1, Ordering::Release); + self.schedule_park_if_closed(); Ok(()) } @@ -349,15 +624,23 @@ impl Service { return view; } let session = self.session.lock().await; - let mut view = match session.as_ref() { - Some(session) if session.phase == ImportPhase::Suspended => { - suspended_state_view(session) + let (mut view, resident_session) = match &*session { + SessionResidency::Resident(Some(session)) + if session.phase == ImportPhase::Suspended => + { + (suspended_state_view(session), Some(session)) + } + SessionResidency::Resident(Some(session)) => (state_view(Some(session)), Some(session)), + SessionResidency::Resident(None) => (state_view(None), None), + SessionResidency::Parked { view, .. } => (view.clone(), None), + SessionResidency::Unhydrated => { + let mut view = state_view(None); + view.sync_problem = Some("Retune is still loading Last.fm import state.".into()); + (view, None) } - Some(session) => state_view(Some(session)), - None => state_view(None), }; let sync = self.sync_state.lock().await; - if let Some(session) = session.as_ref() { + if let Some(session) = resident_session { if matches!(session.phase, ImportPhase::Review | ImportPhase::Done) { view.remaining = remaining_with_apply_queue(session, &sync.apply_queue); } @@ -370,33 +653,97 @@ impl Service { view } + pub(super) async fn snapshot_for_work(&self) -> Result, String> { + let slot = self.lock_resident_session().await?; + let snapshot = match &*slot { + SessionResidency::Resident(Some(session)) => { + let lease = self.session_lease(); + Some(SessionSnapshot { + session: session.clone(), + _lease: lease, + }) + } + SessionResidency::Resident(None) => None, + SessionResidency::Parked { .. } => { + return Err("Last.fm import session remained parked after reload.".into()); + } + SessionResidency::Unhydrated => { + return Err("Retune is still loading Last.fm import state.".into()); + } + }; + drop(slot); + Ok(snapshot) + } + + #[cfg(test)] pub(super) async fn snapshot(&self) -> Option { - self.session.lock().await.clone() + self.snapshot_for_work() + .await + .expect("Last.fm import snapshot should load in test") + .map(|snapshot| snapshot.session) + } + + #[cfg(test)] + pub(super) async fn install_session_for_test(&self, session: LastFmImportSessionV2) { + *self.session.lock().await = SessionResidency::Resident(Some(session)); } pub(super) async fn owner_phase(&self) -> Option { - self.session - .lock() - .await - .as_ref() - .map(|session| ImportOwnerPhase { + match &*self.session.lock().await { + SessionResidency::Resident(Some(session)) => Some(ImportOwnerPhase { cache_id: session.cache_id.clone(), lastfm_username: session.lastfm_username.clone(), spotify_account_id: session.spotify_account_id.clone(), phase: session.phase, - }) + }), + SessionResidency::Parked { owner, .. } => Some(owner.clone()), + SessionResidency::Unhydrated | SessionResidency::Resident(None) => None, + } } pub(super) async fn has_session(&self) -> bool { - self.session.lock().await.is_some() + match &*self.session.lock().await { + SessionResidency::Resident(Some(_)) | SessionResidency::Parked { .. } => true, + SessionResidency::Unhydrated | SessionResidency::Resident(None) => false, + } } + pub(super) async fn snapshot_with_sync_for_work( + &self, + ) -> Result<(Option, LastFmSyncState), String> { + let slot = self.lock_resident_session().await?; + let session = match &*slot { + SessionResidency::Resident(Some(session)) => { + let lease = self.session_lease(); + Some(SessionSnapshot { + session: session.clone(), + _lease: lease, + }) + } + SessionResidency::Resident(None) => None, + SessionResidency::Parked { .. } => { + return Err("Last.fm import session remained parked after reload.".into()); + } + SessionResidency::Unhydrated => { + return Err("Retune is still loading Last.fm import state.".into()); + } + }; + // Preserve the session -> sync lock order to keep the returned pair coherent with the + // persisted review transaction snapshot. + let sync = self.sync_state.lock().await.clone(); + drop(slot); + Ok((session, sync)) + } + + #[cfg(test)] pub(super) async fn snapshot_with_sync( &self, ) -> (Option, LastFmSyncState) { - let session_guard = self.session.lock().await; - let sync = self.sync_state.lock().await.clone(); - (session_guard.clone(), sync) + let (session, sync) = self + .snapshot_with_sync_for_work() + .await + .expect("Last.fm import session should load in test"); + (session.map(|snapshot| snapshot.session), sync) } pub(super) async fn sync_snapshot(&self) -> LastFmSyncState { @@ -419,11 +766,23 @@ impl Service { next.version = LASTFM_SYNC_VERSION; let store = self.incremental_store.clone(); let current = Arc::clone(&self.sync_state); + let restore_mutations = Arc::clone(&self.restore_mutations); tauri::async_runtime::spawn(async move { let saved = next.clone(); - tauri::async_runtime::spawn_blocking(move || store.save(&saved)) - .await - .map_err(|_| "Last.fm incremental sync persistence task stopped.".to_string())??; + let save = match tauri::async_runtime::spawn_blocking(move || store.save(&saved)).await + { + Ok(save) => save, + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(format!( + "Last.fm incremental sync persistence task stopped: {error}" + )); + } + }; + if let Err(error) = save { + restore_mutations.mark_recovery_required(); + return Err(error); + } *current.lock().await = next; drop(mutation_gate); drop(persistence_gate); @@ -474,10 +833,22 @@ impl Service { let store = self.mappings_store.clone(); let current = Arc::clone(&self.mappings); let persisted = next.clone(); + let restore_mutations = Arc::clone(&self.restore_mutations); tauri::async_runtime::spawn(async move { - tauri::async_runtime::spawn_blocking(move || store.save(&persisted)) - .await - .map_err(|_| "Last.fm mappings persistence task stopped.".to_string())??; + let save = + match tauri::async_runtime::spawn_blocking(move || store.save(&persisted)).await { + Ok(save) => save, + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(format!( + "Last.fm mappings persistence task stopped: {error}" + )); + } + }; + if let Err(error) = save { + restore_mutations.mark_recovery_required(); + return Err(error); + } *current.lock().await = next; drop(persistence_gate); Ok::<_, String>(result) @@ -490,6 +861,7 @@ impl Service { &self, persistence_gate: tokio::sync::OwnedMutexGuard<()>, ) -> Result, String> { + let _session_lease = self.session_lease(); let sync_gate = Arc::clone(&self.sync_mutation_gate); let transactions = self.review_transaction_store.clone(); let sessions = self.store.clone(); @@ -498,18 +870,32 @@ impl Service { let current_session = Arc::clone(&self.session); let current_sync = Arc::clone(&self.sync_state); let current_mappings = Arc::clone(&self.mappings); + let restore_mutations = Arc::clone(&self.restore_mutations); + restore_mutations.ensure_allowed()?; tauri::async_runtime::spawn(async move { let _sync_gate = sync_gate.lock_owned().await; - let recovered = tauri::async_runtime::spawn_blocking(move || { + let recovered = match tauri::async_runtime::spawn_blocking(move || { transactions.recover(&sessions, &sync, &mappings) }) .await - .map_err(|_| "Last.fm review transaction recovery task stopped.".to_string())??; + { + Ok(Ok(recovered)) => recovered, + Ok(Err(error)) => { + restore_mutations.mark_recovery_required(); + return Err(error); + } + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(format!( + "Last.fm review transaction recovery task stopped: {error}" + )); + } + }; if let Some(recovered) = recovered { let mut session = current_session.lock().await; let mut sync = current_sync.lock().await; let mut mappings = current_mappings.lock().await; - *session = recovered.session; + *session = SessionResidency::Resident(recovered.session); if let Some(sync_state) = recovered.sync_state { *sync = sync_state; } @@ -534,30 +920,56 @@ impl Service { let persistence_gate = self .recover_pending_review_transaction(persistence_gate) .await?; - let previous_session = self.session.lock().await.clone(); + self.ensure_session_resident_under_gate().await?; + let _session_lease = self.session_lease(); + let previous_session = match &*self.session.lock().await { + SessionResidency::Resident(session) => session.clone(), + _ => unreachable!("Last.fm session must be resident under persistence gate"), + }; let previous_mappings = self.mappings.lock().await.clone(); let (next_session, next_mappings, result) = - mutation(previous_session.clone(), previous_mappings.clone())?; - if previous_session.as_ref() == Some(&next_session) && previous_mappings == next_mappings { + mutation(previous_session, previous_mappings.clone())?; + let session_unchanged = matches!( + &*self.session.lock().await, + SessionResidency::Resident(Some(current)) if current == &next_session + ); + if session_unchanged && previous_mappings == next_mappings { return Ok(result); } self.restore_mutations.ensure_allowed()?; - let transaction = ReviewTransaction::new(next_session.clone(), next_mappings.clone()); + let transaction = ReviewTransaction::new(Some(next_session), next_mappings); let transactions = self.review_transaction_store.clone(); let sessions = self.store.clone(); let mappings = self.mappings_store.clone(); let current_session = Arc::clone(&self.session); let current_mappings = Arc::clone(&self.mappings); + let restore_mutations = Arc::clone(&self.restore_mutations); tauri::async_runtime::spawn(async move { - tauri::async_runtime::spawn_blocking(move || { + let saved = tauri::async_runtime::spawn_blocking(move || { transactions.save(&transaction)?; sessions.save(transaction.session.as_ref().expect("transaction session"))?; mappings.save(&transaction.mappings)?; - transactions.clear() + transactions.clear()?; + Ok::<_, String>(( + transaction.session.expect("transaction session"), + transaction.mappings, + )) }) - .await - .map_err(|_| "Last.fm review transaction persistence task stopped.".to_string())??; - *current_session.lock().await = Some(next_session); + .await; + let (next_session, next_mappings) = match saved { + Ok(Ok(saved)) => saved, + Ok(Err(error)) => { + restore_mutations.mark_recovery_required(); + return Err(error); + } + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(format!( + "Last.fm review transaction persistence task stopped: {error}" + )); + } + }; + *current_session.lock().await = SessionResidency::Resident(Some(next_session)); *current_mappings.lock().await = next_mappings; drop(persistence_gate); Ok::<_, String>(result) @@ -566,7 +978,6 @@ impl Service { .map_err(|error| error.to_string())? } - #[allow(dead_code)] async fn mutate_review_state_queued(&self, mutation: F) -> Result where F: FnOnce( @@ -580,62 +991,27 @@ impl Service { let persistence_gate = self .recover_pending_review_transaction(persistence_gate) .await?; - let previous_session = self.session.lock().await.clone(); + self.ensure_session_resident_under_gate().await?; + let _session_lease = self.session_lease(); + let previous_session = match &*self.session.lock().await { + SessionResidency::Resident(session) => session.clone(), + _ => unreachable!("Last.fm session must be resident under persistence gate"), + }; let previous_mappings = self.mappings.lock().await.clone(); let (next_session, next_mappings, result) = - mutation(previous_session.clone(), previous_mappings.clone())?; - if previous_session.as_ref() == Some(&next_session) && previous_mappings == next_mappings { + mutation(previous_session, previous_mappings.clone())?; + let session_unchanged = matches!( + &*self.session.lock().await, + SessionResidency::Resident(Some(current)) if current == &next_session + ); + if session_unchanged && previous_mappings == next_mappings { drop(persistence_gate); return Ok(result); } self.restore_mutations.ensure_allowed()?; - *self.session.lock().await = Some(next_session.clone()); - *self.mappings.lock().await = next_mappings.clone(); - let queue = Arc::clone(&self.review_writes); - *queue.pending.lock().await = Some((next_session, next_mappings)); - if !queue.running.swap(true, Ordering::AcqRel) { - let transactions = self.review_transaction_store.clone(); - let sessions = self.store.clone(); - let mappings = self.mappings_store.clone(); - tauri::async_runtime::spawn(async move { - loop { - let (session, persisted) = { - let mut pending = queue.pending.lock().await; - match pending.take() { - Some(next) => next, - None => { - queue.running.store(false, Ordering::Release); - return; - } - } - }; - let transaction = ReviewTransaction::new(session, persisted); - let transactions = transactions.clone(); - let sessions = sessions.clone(); - let mappings = mappings.clone(); - let result = tauri::async_runtime::spawn_blocking(move || { - transactions.save(&transaction)?; - if let Some(session) = transaction.session.as_ref() { - sessions.save(session)?; - } - mappings.save(&transaction.mappings)?; - transactions.clear() - }) - .await; - match result { - Ok(Ok(())) => {} - Ok(Err(error)) => log::warn!( - target: "lastfm_import", - "queued review save failed: {error}" - ), - Err(error) => log::warn!( - target: "lastfm_import", - "queued review save task stopped: {error}" - ), - } - } - }); - } + *self.session.lock().await = SessionResidency::Resident(Some(next_session)); + *self.mappings.lock().await = next_mappings; + self.enqueue_review_save().await; drop(persistence_gate); Ok(result) } @@ -695,7 +1071,7 @@ impl Service { pub(crate) async fn backfill_completed_mappings(&self) -> Result<(), String> { self.ensure_hydrated()?; - let Some(session) = self.snapshot().await else { + let Some(session) = self.snapshot_for_work().await? else { return Ok(()); }; if !review_phase_allowed(session.phase) || session.spotify_account_id.is_none() { @@ -781,7 +1157,7 @@ impl Service { spotify_account_id: Option<&str>, ) -> Result<(), String> { let backlog = self.sync_snapshot().await.backlog; - if backlog.is_empty() && self.snapshot().await.is_none() { + if backlog.is_empty() && !self.has_session().await { return Ok(()); } self.mutate_session(|current| { @@ -991,7 +1367,7 @@ impl Service { } pub(super) fn claim_sync_runner(&self) -> Option { - RunnerGuard::claim(&self.sync_running) + RunnerGuard::claim(&self.sync_running, &self.self_weak) } #[cfg(test)] @@ -999,40 +1375,176 @@ impl Service { self.mutate_session(|_| Ok((Some(session), ()))).await } - async fn enqueue_session_save(&self, session: LastFmImportSessionV2) { + async fn enqueue_session_save(&self) { let queue = Arc::clone(&self.session_writes); - *queue.pending.lock().await = Some(session); + *queue.pending.lock().await = true; if queue.running.swap(true, Ordering::AcqRel) { return; } let store = self.store.clone(); + let persistence_gate = Arc::clone(&self.persistence_gate); + let current = Arc::clone(&self.session); + let restore_mutations = Arc::clone(&self.restore_mutations); + let service = self.self_weak.clone(); tauri::async_runtime::spawn(async move { loop { - let next = { + let persistence_gate = persistence_gate.clone().lock_owned().await; + if restore_mutations.ensure_allowed().is_err() { + queue.running.store(false, Ordering::Release); + drop(persistence_gate); + request_park_after_write(&service); + return; + } + let dirty = { let mut pending = queue.pending.lock().await; - match pending.take() { - Some(next) => next, - None => { + std::mem::take(&mut *pending) + }; + if !dirty { + queue.running.store(false, Ordering::Release); + drop(persistence_gate); + request_park_after_write(&service); + return; + } + let next = { + let current = current.lock().await; + match &*current { + SessionResidency::Resident(session) => session.clone(), + SessionResidency::Parked { .. } | SessionResidency::Unhydrated => { + restore_mutations.mark_recovery_required(); queue.running.store(false, Ordering::Release); + drop(persistence_gate); + request_park_after_write(&service); return; } } }; + let Some(next) = next else { + // A durable invalidation can supersede a queued session while this worker + // waits for the gate. The invalidation already removed its persisted state; + // never resurrect that stale snapshot. + drop(persistence_gate); + continue; + }; let result = tauri::async_runtime::spawn_blocking({ let store = store.clone(); move || store.save(&next) }) .await; match result { - Ok(Ok(())) => {} - Ok(Err(error)) => log::warn!( - target: "lastfm_import", - "queued metadata save failed: {error}" - ), - Err(error) => log::warn!( - target: "lastfm_import", - "queued metadata save task stopped: {error}" - ), + Ok(Ok(())) => drop(persistence_gate), + Ok(Err(error)) => { + restore_mutations.mark_recovery_required(); + queue.running.store(false, Ordering::Release); + log::error!( + target: "lastfm_import", + "queued metadata save failed; restart Retune to recover: {error}" + ); + drop(persistence_gate); + request_park_after_write(&service); + return; + } + Err(error) => { + restore_mutations.mark_recovery_required(); + queue.running.store(false, Ordering::Release); + log::error!( + target: "lastfm_import", + "queued metadata save task stopped; restart Retune to recover: {error}" + ); + drop(persistence_gate); + request_park_after_write(&service); + return; + } + } + } + }); + } + + async fn enqueue_review_save(&self) { + let queue = Arc::clone(&self.review_writes); + *queue.pending.lock().await = true; + if queue.running.swap(true, Ordering::AcqRel) { + return; + } + let transactions = self.review_transaction_store.clone(); + let sessions = self.store.clone(); + let mappings_store = self.mappings_store.clone(); + let persistence_gate = Arc::clone(&self.persistence_gate); + let current_session = Arc::clone(&self.session); + let current_mappings = Arc::clone(&self.mappings); + let restore_mutations = Arc::clone(&self.restore_mutations); + let service = self.self_weak.clone(); + tauri::async_runtime::spawn(async move { + loop { + let persistence_gate = persistence_gate.clone().lock_owned().await; + if restore_mutations.ensure_allowed().is_err() { + queue.running.store(false, Ordering::Release); + drop(persistence_gate); + request_park_after_write(&service); + return; + } + let dirty = { + let mut pending = queue.pending.lock().await; + std::mem::take(&mut *pending) + }; + if !dirty { + queue.running.store(false, Ordering::Release); + drop(persistence_gate); + request_park_after_write(&service); + return; + } + let session = { + let current_session = current_session.lock().await; + match &*current_session { + SessionResidency::Resident(session) => session.clone(), + SessionResidency::Parked { .. } | SessionResidency::Unhydrated => { + restore_mutations.mark_recovery_required(); + queue.running.store(false, Ordering::Release); + drop(persistence_gate); + request_park_after_write(&service); + return; + } + } + }; + let mappings = current_mappings.lock().await.clone(); + let transaction = ReviewTransaction::new(session, mappings); + let result = tauri::async_runtime::spawn_blocking({ + let transactions = transactions.clone(); + let sessions = sessions.clone(); + let mappings_store = mappings_store.clone(); + move || { + transactions.save(&transaction)?; + if let Some(session) = transaction.session.as_ref() { + sessions.save(session)?; + } + mappings_store.save(&transaction.mappings)?; + transactions.clear() + } + }) + .await; + match result { + Ok(Ok(())) => drop(persistence_gate), + Ok(Err(error)) => { + restore_mutations.mark_recovery_required(); + queue.running.store(false, Ordering::Release); + log::error!( + target: "lastfm_import", + "queued review save failed; restart Retune to recover: {error}" + ); + drop(persistence_gate); + request_park_after_write(&service); + return; + } + Err(error) => { + restore_mutations.mark_recovery_required(); + queue.running.store(false, Ordering::Release); + log::error!( + target: "lastfm_import", + "queued review save task stopped; restart Retune to recover: {error}" + ); + drop(persistence_gate); + request_park_after_write(&service); + return; + } } } }); @@ -1050,24 +1562,45 @@ impl Service { let persistence_gate = self .recover_pending_review_transaction(persistence_gate) .await?; - let previous = self.session.lock().await.clone(); - let (next, result) = mutation(previous.clone())?; - if next != previous { + self.ensure_session_resident_under_gate().await?; + let _session_lease = self.session_lease(); + let previous = match &*self.session.lock().await { + SessionResidency::Resident(session) => session.clone(), + _ => unreachable!("Last.fm session must be resident under persistence gate"), + }; + let (next, result) = mutation(previous)?; + let unchanged = matches!( + &*self.session.lock().await, + SessionResidency::Resident(current) if current == &next + ); + if !unchanged { let store = self.store.clone(); let current = Arc::clone(&self.session); + let restore_mutations = Arc::clone(&self.restore_mutations); return tauri::async_runtime::spawn(async move { let next = if let Some(session) = next { - Some( - tauri::async_runtime::spawn_blocking(move || { - store.save(&session).map(|()| session) - }) - .await - .map_err(|_| "Last.fm import persistence task stopped.".to_string())??, - ) + let saved = match tauri::async_runtime::spawn_blocking(move || { + store.save(&session).map(|()| session) + }) + .await + { + Ok(Ok(saved)) => saved, + Ok(Err(error)) => { + restore_mutations.mark_recovery_required(); + return Err(error); + } + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(format!( + "Last.fm import persistence task stopped: {error}" + )); + } + }; + Some(saved) } else { None }; - *current.lock().await = next; + *current.lock().await = SessionResidency::Resident(next); drop(persistence_gate); Ok::<_, String>(result) }) @@ -1089,13 +1622,23 @@ impl Service { let persistence_gate = self .recover_pending_review_transaction(persistence_gate) .await?; - let previous = self.session.lock().await.clone(); - let (next, result) = mutation(previous.clone())?; - if next != previous { + self.ensure_session_resident_under_gate().await?; + let _session_lease = self.session_lease(); + let previous = match &*self.session.lock().await { + SessionResidency::Resident(session) => session.clone(), + _ => unreachable!("Last.fm session must be resident under persistence gate"), + }; + let (next, result) = mutation(previous)?; + let unchanged = matches!( + &*self.session.lock().await, + SessionResidency::Resident(current) if current == &next + ); + if !unchanged { self.restore_mutations.ensure_allowed()?; - *self.session.lock().await = next.clone(); - if let Some(session) = next { - self.enqueue_session_save(session).await; + let has_session = next.is_some(); + *self.session.lock().await = SessionResidency::Resident(next); + if has_session { + self.enqueue_session_save().await; } } drop(persistence_gate); @@ -1208,8 +1751,13 @@ impl Service { let persistence_gate = self .recover_pending_review_transaction(persistence_gate) .await?; + self.ensure_session_resident_under_gate().await?; + let _session_lease = self.session_lease(); let sync_gate = Arc::clone(&self.sync_mutation_gate).lock_owned().await; - let previous_session = self.session.lock().await.clone(); + let previous_session = match &*self.session.lock().await { + SessionResidency::Resident(session) => session.clone(), + _ => unreachable!("Last.fm session must be resident under persistence gate"), + }; let previous_sync = self.sync_state.lock().await.clone(); let previous_mappings = self.mappings.lock().await.clone(); let mut next_session = previous_session.clone(); @@ -1255,8 +1803,9 @@ impl Service { let current_session = Arc::clone(&self.session); let current_sync = Arc::clone(&self.sync_state); let current_mappings = Arc::clone(&self.mappings); + let restore_mutations = Arc::clone(&self.restore_mutations); tauri::async_runtime::spawn(async move { - tauri::async_runtime::spawn_blocking(move || { + let saved = tauri::async_runtime::spawn_blocking(move || { transactions.save(&transaction)?; if let Some(session) = transaction.session.as_ref() { sessions.save(session)?; @@ -1267,12 +1816,24 @@ impl Service { mappings.save(&transaction.mappings)?; transactions.clear() }) - .await - .map_err(|_| "Last.fm account migration persistence task stopped.".to_string())??; + .await; + match saved { + Ok(Ok(())) => {} + Ok(Err(error)) => { + restore_mutations.mark_recovery_required(); + return Err(error); + } + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(format!( + "Last.fm account migration persistence task stopped: {error}" + )); + } + } let mut session = current_session.lock().await; let mut sync = current_sync.lock().await; let mut mappings = current_mappings.lock().await; - *session = next_session; + *session = SessionResidency::Resident(next_session); *sync = next_sync; *mappings = next_mappings; drop(sync_gate); @@ -1293,17 +1854,24 @@ impl Service { if let Some(defaults) = &defaults { defaults.validate()?; } - if let Some(session) = self.snapshot().await { + if let Some(session) = self.snapshot_for_work().await? { if suspended_source_phase(&session) { let store = self.store.clone(); - let validation_session = session.clone(); + let cache_id = session.cache_id.clone(); + let lastfm_username = session.lastfm_username.clone(); + let history_to = session.history_to; + let SessionSnapshot { + session: validation_session, + _lease, + } = session; let cache_valid = tauri::async_runtime::spawn_blocking(move || { store.validate_cache(&validation_session).is_ok() }) .await .map_err(|_| "Last.fm import cache validation task stopped.".to_string())?; if !cache_valid { - self.invalidate_snapshot_if_same(&session).await?; + self.invalidate_snapshot_if_identity(&cache_id, &lastfm_username, history_to) + .await?; } } } @@ -1367,13 +1935,15 @@ impl Service { return Err("No Last.fm import session is active.".into()); }; if session.phase != ImportPhase::Downloading { - return Ok((Some(session.clone()), state_view(Some(&session)))); + let view = state_view(Some(&session)); + return Ok((Some(session), view)); } if let Some(existing) = session.total_pages { if existing != total_pages { return Err("Last.fm import metadata changed during the snapshot.".into()); } - return Ok((Some(session.clone()), state_view(Some(&session)))); + let view = state_view(Some(&session)); + return Ok((Some(session), view)); } session.total_pages = Some(total_pages); session.total_scrobbles = total_scrobbles; @@ -1382,7 +1952,8 @@ impl Service { if total_pages == 0 { session.phase = ImportPhase::Aggregating; } - Ok((Some(session.clone()), state_view(Some(&session)))) + let view = state_view(Some(&session)); + Ok((Some(session), view)) }) .await } @@ -1392,7 +1963,7 @@ impl Service { page: u32, parsed: &ParsedRecentTracksPage, ) -> Result { - let Some(before) = self.snapshot().await else { + let Some(before) = self.snapshot_for_work().await? else { return Err("No Last.fm import session is active.".into()); }; if before.phase != ImportPhase::Downloading { @@ -1422,10 +1993,14 @@ impl Service { } return Ok(state_view(Some(&before))); } - let mut cache_session = before.clone(); + let history_to = before.history_to; + let SessionSnapshot { + session: mut cache_session, + _lease, + } = before; cache_session.total_pages = Some(total_pages); let mut filtered = parsed.clone(); - discard_post_cutoff(&mut filtered, before.history_to); + discard_post_cutoff(&mut filtered, history_to); let store = self.store.clone(); let cached_page = filtered.clone(); tauri::async_runtime::spawn_blocking(move || { @@ -1439,7 +2014,8 @@ impl Service { return Err("No Last.fm import session is active.".into()); }; if session.phase != ImportPhase::Downloading { - return Ok((Some(session.clone()), state_view(Some(&session)))); + let view = state_view(Some(&session)); + return Ok((Some(session), view)); } if session.next_page != 0 && session.next_page != page { return Err("Last.fm import cursor changed before page acknowledgement.".into()); @@ -1475,7 +2051,8 @@ impl Service { session.phase = ImportPhase::Aggregating; } session.retryable_error = None; - Ok((Some(session.clone()), state_view(Some(&session)))) + let view = state_view(Some(&session)); + Ok((Some(session), view)) }) .await?; Ok(result) @@ -1534,14 +2111,21 @@ impl Service { &self, lastfm: Option<&crate::lastfm::Service>, ) -> Result { - let Some(session) = self.snapshot().await else { + let Some(session) = self.snapshot_for_work().await? else { return Err("No Last.fm import session is active.".into()); }; if session.phase != ImportPhase::Aggregating { return Ok(state_view(Some(&session))); } let store = self.store.clone(); - let blocking_session = session.clone(); + let cache_id = session.cache_id.clone(); + let lastfm_username = session.lastfm_username.clone(); + let spotify_account_id = session.spotify_account_id.clone(); + let history_to = session.history_to; + let SessionSnapshot { + session: blocking_session, + _lease, + } = session; let aggregation = tauri::async_runtime::spawn_blocking(move || { let mut scrobbles = store.read_pages(&blocking_session)?; sort_scrobbles(&mut scrobbles); @@ -1555,7 +2139,8 @@ impl Service { let (rows, batches) = match aggregation { Ok(result) => result, Err(error) => { - self.invalidate_snapshot().await?; + self.invalidate_snapshot_if_identity(&cache_id, &lastfm_username, history_to) + .await?; return Err(error); } }; @@ -1564,8 +2149,7 @@ impl Service { let Some(mut current) = current else { return Err("No Last.fm import session is active.".into()); }; - if current.cache_id != session.cache_id || current.phase != ImportPhase::Aggregating - { + if current.cache_id != cache_id || current.phase != ImportPhase::Aggregating { return Err("Last.fm import changed while aggregation was running.".into()); } current.rows = rows; @@ -1577,15 +2161,13 @@ impl Service { ImportPhase::Review }; current.retryable_error = None; - Ok((Some(current.clone()), state_view(Some(¤t)))) + let view = state_view(Some(¤t)); + Ok((Some(current), view)) }) .await }; let result = match lastfm { - Some(lastfm) => match lastfm - .with_import_owner(&session.lastfm_username, commit) - .await? - { + Some(lastfm) => match lastfm.with_import_owner(&lastfm_username, commit).await? { Some(result) => result, None => { self.suspend_for_account_mismatch().await?; @@ -1594,45 +2176,60 @@ impl Service { }, None => commit().await?, }; - if let Err(error) = self.remove_snapshot(&session.cache_id).await { + if let Err(error) = self.remove_snapshot(&cache_id).await { log::warn!("Could not remove completed Last.fm import cache: {error}"); } - self.sync_backlog_into_review( - &session.lastfm_username, - session.spotify_account_id.as_deref(), - ) - .await?; + self.sync_backlog_into_review(&lastfm_username, spotify_account_id.as_deref()) + .await?; Ok(result) } + #[cfg(test)] pub(super) async fn invalidate_snapshot(&self) -> Result<(), String> { - let Some(session) = self.snapshot().await else { + let Some(session) = self.snapshot_for_work().await? else { return Ok(()); }; - self.invalidate_snapshot_if_same(&session).await + self.invalidate_snapshot_if_identity( + &session.cache_id, + &session.lastfm_username, + session.history_to, + ) + .await } - async fn invalidate_snapshot_if_same( + async fn invalidate_snapshot_if_identity( &self, - expected: &LastFmImportSessionV2, + cache_id: &str, + username: &str, + history_to: u64, ) -> Result<(), String> { self.ensure_hydrated()?; let persistence_gate = Arc::clone(&self.persistence_gate).lock_owned().await; let persistence_gate = self .recover_pending_review_transaction(persistence_gate) .await?; - let same_source = self.session.lock().await.as_ref().is_some_and(|current| { - current.cache_id == expected.cache_id - && current.lastfm_username == expected.lastfm_username - && current.history_to == expected.history_to - }); + self.ensure_session_resident_under_gate().await?; + let _session_lease = self.session_lease(); + let same_source = match &*self.session.lock().await { + SessionResidency::Resident(Some(current)) => { + current.cache_id == cache_id + && current.lastfm_username == username + && current.history_to == history_to + } + SessionResidency::Resident(None) => false, + SessionResidency::Parked { .. } => { + return Err("Last.fm import session remained parked after reload.".into()); + } + SessionResidency::Unhydrated => { + return Err("Retune is still loading Last.fm import state.".into()); + } + }; if !same_source { return Ok(()); } let store = self.store.clone(); - let cache_id = expected.cache_id.clone(); - let username = expected.lastfm_username.clone(); - let history_to = expected.history_to; + let cache_id = cache_id.to_owned(); + let username = username.to_owned(); let current = Arc::clone(&self.session); tauri::async_runtime::spawn(async move { let quarantine_cache_id = cache_id.clone(); @@ -1643,12 +2240,17 @@ impl Service { .await .map_err(|_| "Last.fm import quarantine task stopped.".to_string())??; let mut session = current.lock().await; - if session.as_ref().is_some_and(|current| { - current.cache_id == cache_id - && current.lastfm_username == username - && current.history_to == history_to - }) { - *session = None; + let same_source = match &*session { + SessionResidency::Resident(Some(current)) => { + current.cache_id == cache_id + && current.lastfm_username == username + && current.history_to == history_to + } + SessionResidency::Parked { .. } => false, + SessionResidency::Unhydrated | SessionResidency::Resident(None) => false, + }; + if same_source { + *session = SessionResidency::Resident(None); } drop(persistence_gate); Ok::<_, String>(()) @@ -2043,11 +2645,6 @@ impl Service { target_uri: &str, mode: CountMode, ) -> Result<(), String> { - #[cfg(test)] - return self - .set_count_mode(username, spotify_account_id, target_uri, mode) - .await; - #[cfg(not(test))] self.mutate_review_state_queued(|session, persisted| { let (session, persisted) = set_count_mode_in_review( session, @@ -2114,37 +2711,68 @@ impl Service { "Last.fm import queue limit must be between 1 and {LASTFM_QUEUE_PAGE_LIMIT}." )); } - let session_guard = self.session.lock().await; - if session_guard - .as_ref() - .is_none_or(|session| session.phase == ImportPhase::Suspended) - { - return queue_page_view( - session_guard.as_ref(), - &LastFmSyncState::default(), - cursor, - limit, - ); - } - let sync = self.sync_snapshot().await; - queue_page_view(session_guard.as_ref(), &sync, cursor, limit) + let session = self.lock_resident_session().await?; + let session_lease = self.session_lease(); + let result = match &*session { + SessionResidency::Resident(Some(session)) + if session.phase != ImportPhase::Suspended => + { + let sync = self.sync_state.lock().await; + queue_page_view(Some(session), &sync, cursor, limit) + } + SessionResidency::Resident(Some(_)) | SessionResidency::Resident(None) => { + queue_page_view(None, &LastFmSyncState::default(), cursor, limit) + } + SessionResidency::Parked { .. } => { + Err("Last.fm import session remained parked after reload.".into()) + } + SessionResidency::Unhydrated => { + Err("Retune is still loading Last.fm import state.".into()) + } + }; + drop(session); + drop(session_lease); + result } + pub(crate) async fn page_for_work( + &self, + batch_id: u32, + artist: &str, + album: &str, + ) -> Result, String> { + let session = self.lock_resident_session().await?; + let session_lease = self.session_lease(); + let result = match &*session { + SessionResidency::Resident(Some(session)) + if session.phase != ImportPhase::Suspended => + { + let sync = self.sync_state.lock().await; + Ok(page_view(Some(session), &sync, batch_id, artist, album)) + } + SessionResidency::Resident(Some(_)) | SessionResidency::Resident(None) => Ok(None), + SessionResidency::Parked { .. } => { + Err("Last.fm import session remained parked after reload.".into()) + } + SessionResidency::Unhydrated => { + Err("Retune is still loading Last.fm import state.".into()) + } + }; + drop(session); + drop(session_lease); + result + } + + #[cfg(test)] pub(crate) async fn page( &self, batch_id: u32, artist: &str, album: &str, ) -> Option { - let session_guard = self.session.lock().await; - if session_guard - .as_ref() - .is_none_or(|session| session.phase == ImportPhase::Suspended) - { - return None; - } - let sync = self.sync_snapshot().await; - page_view(session_guard.as_ref(), &sync, batch_id, artist, album) + self.page_for_work(batch_id, artist, album) + .await + .expect("Last.fm import page should load in test") } #[cfg(test)] @@ -2388,7 +3016,7 @@ impl Service { pub(super) fn claim_runner(&self) -> Option { self.ensure_hydrated().ok()?; - RunnerGuard::claim(&self.running) + RunnerGuard::claim(&self.running, &self.self_weak) } } @@ -2396,7 +3024,23 @@ impl Service { mod hydration_tests { use std::{sync::Arc, time::Duration}; - use super::{load_importer_stores, Service}; + use super::{load_importer_stores, model::*, ImportSessionStore, Service, SessionResidency}; + + async fn wait_until_parked(service: &Service) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if matches!( + &*service.session.lock().await, + SessionResidency::Parked { .. } + ) { + return; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .expect("closed importer session should be parked"); + } #[tokio::test(flavor = "current_thread")] async fn delayed_importer_store_load_does_not_block_the_async_worker() { @@ -2474,6 +3118,152 @@ mod hydration_tests { .unwrap(); } + #[tokio::test] + async fn startup_hydration_parks_an_idle_importer_without_opening_its_window() { + let directory = tempfile::tempdir().unwrap(); + let mut session = super::LastFmImportSessionV2::new("user".into(), "spotify".into(), 1); + session.phase = super::ImportPhase::Review; + ImportSessionStore::new(directory.path()) + .save(&session) + .unwrap(); + let service = Service::new_unhydrated_with_restore_state( + directory.path(), + Arc::new(crate::restore_latch::RestoreMutationState::default()), + ); + + service.hydrate().await.unwrap(); + wait_until_parked(&service).await; + + assert!(service.has_session().await); + assert_eq!(service.state().await.username.as_deref(), Some("user")); + } + + #[tokio::test] + async fn closing_during_a_session_lease_defers_then_retries_parking() { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path()); + let mut expected = super::LastFmImportSessionV2::new("user".into(), "spotify".into(), 1); + expected.phase = super::ImportPhase::Review; + service.save(expected.clone()).await.unwrap(); + let snapshot = service.snapshot_for_work().await.unwrap().unwrap(); + + service.set_importer_window_open(false); + tokio::time::timeout(Duration::from_millis(250), service.park_if_closed()) + .await + .expect("parking should not wait for an active lease") + .unwrap(); + assert!(matches!( + &*service.session.lock().await, + SessionResidency::Resident(Some(_)) + )); + + drop(snapshot); + wait_until_parked(&service).await; + let reloaded = service.snapshot_for_work().await.unwrap().unwrap(); + assert_eq!(reloaded.session, expected); + assert_eq!(service.state().await.username.as_deref(), Some("user")); + } + + #[tokio::test(flavor = "current_thread")] + async fn queued_save_completes_before_the_tail_retry_parks() { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path()); + let mut expected = super::LastFmImportSessionV2::new("user".into(), "spotify".into(), 1); + expected.phase = super::ImportPhase::Review; + let hook = crate::store::SaveHook::new(false); + service.store.arm_save(Arc::clone(&hook)); + + service + .mutate_session_queued(|_| Ok((Some(expected.clone()), ()))) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while !hook.is_reached() { + tokio::task::yield_now().await; + } + }) + .await + .expect("queued save should reach its store hook"); + hook.wait_until_reached(); + + service.set_importer_window_open(false); + tokio::time::timeout(Duration::from_millis(250), service.park_if_closed()) + .await + .expect("parking should return while a queued writer is active") + .unwrap(); + assert!(matches!( + &*service.session.lock().await, + SessionResidency::Resident(Some(_)) + )); + + hook.release(); + tokio::time::timeout(Duration::from_secs(5), service.wait_for_queued_writes()) + .await + .expect("queued save should finish"); + wait_until_parked(&service).await; + + let reloaded = Service::new(directory.path()); + assert_eq!(reloaded.snapshot().await, Some(expected)); + } + + #[tokio::test(flavor = "current_thread")] + async fn failed_queued_save_keeps_the_session_resident_for_recovery() { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path()); + let mut expected = super::LastFmImportSessionV2::new("user".into(), "spotify".into(), 1); + expected.phase = super::ImportPhase::Review; + let hook = crate::store::SaveHook::new(true); + service.store.arm_save(Arc::clone(&hook)); + + service + .mutate_session_queued(|_| Ok((Some(expected), ()))) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while !hook.is_reached() { + tokio::task::yield_now().await; + } + }) + .await + .expect("queued save should reach its store hook"); + hook.wait_until_reached(); + hook.release(); + tokio::time::timeout(Duration::from_secs(5), service.wait_for_queued_writes()) + .await + .expect("failed queued save should finish"); + + assert!(service.close_importer_window_and_park().await.is_err()); + assert!(matches!( + &*service.session.lock().await, + SessionResidency::Resident(Some(_)) + )); + assert!(service.has_session().await); + } + + #[tokio::test] + async fn failed_parked_reload_keeps_the_cached_owner_and_reports_error() { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path()); + let mut session = super::LastFmImportSessionV2::new("user".into(), "spotify".into(), 1); + session.phase = super::ImportPhase::Review; + service.save(session).await.unwrap(); + service.set_importer_window_open(false); + wait_until_parked(&service).await; + std::fs::remove_file(&service.store.path).unwrap(); + + let reload = service.snapshot_for_work().await; + assert!(matches!( + reload, + Err(ref error) if error.contains("parked Last.fm import session is missing from disk") + )); + assert!(service.has_session().await); + assert_eq!(service.state().await.username.as_deref(), Some("user")); + assert!(matches!( + &*service.session.lock().await, + SessionResidency::Parked { .. } + )); + } + #[tokio::test] async fn queued_session_mutation_publishes_memory_immediately() { let directory = tempfile::tempdir().unwrap(); @@ -2485,4 +3275,92 @@ mod hydration_tests { .unwrap(); assert_eq!(service.snapshot().await, Some(session)); } + + #[tokio::test(flavor = "current_thread")] + async fn queued_review_save_keeps_mappings_after_session_reset() { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path()); + let mappings = PersistedLastFmMappings { + version: super::LASTFM_MAPPINGS_VERSION, + lastfm_username: Some("user".into()), + spotify_account_id: Some("spotify".into()), + mappings: LastFmMappings { + default_count_mode: CountMode::Overwrite, + ..LastFmMappings::default() + }, + ..PersistedLastFmMappings::default() + }; + *service.session.lock().await = super::SessionResidency::Resident(None); + *service.mappings.lock().await = mappings.clone(); + + service.enqueue_review_save().await; + tokio::time::timeout(Duration::from_secs(5), service.wait_for_queued_writes()) + .await + .expect("queued review save should finish"); + + let reloaded = Service::new(directory.path()); + assert!(reloaded.snapshot().await.is_none()); + assert_eq!(reloaded.export_mappings().await, mappings); + } + + #[tokio::test(flavor = "current_thread")] + async fn queued_review_save_cannot_overwrite_newer_durable_state() { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path()); + let mut session = LastFmImportSessionV2::new("user".into(), "spotify".into(), 1); + session.phase = ImportPhase::Review; + service.save(session).await.unwrap(); + service + .save_mappings_for("user", Some("spotify"), LastFmMappings::default()) + .await + .unwrap(); + + let hook = crate::store::SaveHook::new(false); + service.mappings_store.arm_save(Arc::clone(&hook)); + let queued = { + let service = Arc::clone(&service); + tokio::spawn(async move { + service + .set_count_mode_queued( + "user", + "spotify", + "spotify:track:queued", + CountMode::Zero, + ) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(5), async { + while !hook.is_reached() { + tokio::task::yield_now().await; + } + }) + .await + .expect("queued review save should reach its store hook"); + hook.wait_until_reached(); + + let durable = { + let service = Arc::clone(&service); + tokio::spawn(async move { service.set_search_terms("user", "spotify", true).await }) + }; + tokio::task::yield_now().await; + assert!(!durable.is_finished()); + + hook.release(); + queued.await.unwrap().unwrap(); + durable.await.unwrap().unwrap(); + + let reloaded = Service::new(directory.path()); + let session = reloaded.snapshot().await.unwrap(); + assert!(session.search_terms); + assert_eq!(session.default_count_mode, CountMode::Zero); + assert_eq!( + reloaded + .mappings_for("user", Some("spotify")) + .await + .unwrap() + .default_count_mode, + CountMode::Zero + ); + } } diff --git a/apps/desktop/src-tauri/src/lastfm_import/source.rs b/apps/desktop/src-tauri/src/lastfm_import/source.rs index c016ee6..1eb81ea 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/source.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/source.rs @@ -366,9 +366,9 @@ where } SourcePageFetchResult::Retryable(message) => { let attempt = service - .snapshot() - .await - .and_then(|session| session.retryable_error) + .snapshot_for_work() + .await? + .and_then(|snapshot| snapshot.session.retryable_error) .filter(|error| error.retryable) .map(|error| error.attempt.saturating_add(1)) .unwrap_or(1); @@ -445,6 +445,7 @@ pub(super) async fn fetch_source_page( } } +#[cfg(test)] pub(super) fn startup_resume_plan( session: Option<&LastFmImportSessionV2>, ) -> Option<(String, u64)> { @@ -458,6 +459,7 @@ pub(super) fn startup_resume_plan( .map(|session| (session.lastfm_username.clone(), session.history_to)) } +#[cfg(test)] pub(super) fn startup_lastfm_identity_matches( session: &LastFmImportSessionV2, live_username: Option<&str>, @@ -478,12 +480,13 @@ pub(super) async fn run_import( let generation = lastfm.import_generation(); let result = async { loop { - let Some(session) = service.snapshot().await else { + let Some(snapshot) = service.snapshot_for_work().await? else { break; }; + let session = &snapshot.session; match session.phase { ImportPhase::Downloading => { - match source_runner_step(&session) { + match source_runner_step(session) { SourceRunnerStep::Probe => { let payload = fetch_import_page_with_retry( &lastfm, @@ -617,9 +620,9 @@ async fn fetch_import_page_with_retry( } Err(error) if error.retryable => { let attempt = service - .snapshot() - .await - .and_then(|session| session.retryable_error) + .snapshot_for_work() + .await? + .and_then(|snapshot| snapshot.session.retryable_error) .map(|error| error.attempt.saturating_add(1)) .unwrap_or(1); service diff --git a/apps/desktop/src-tauri/src/lastfm_import/store.rs b/apps/desktop/src-tauri/src/lastfm_import/store.rs index c1ae4b4..c8c2df8 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/store.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/store.rs @@ -15,7 +15,7 @@ use super::{ MAX_SERIALIZED_SESSION_BYTES, SESSION_VERSION, }; -use crate::persistence::read_limited; +use crate::persistence::{durable_remove, read_limited, sync_parent}; const MAX_INCREMENTAL_STATE_BYTES: u64 = 256 * 1024 * 1024; const MAX_MAPPINGS_BYTES: u64 = 100 * 1024 * 1024; @@ -39,10 +39,13 @@ pub(super) struct ReviewTransaction { } impl ReviewTransaction { - pub(super) fn new(session: LastFmImportSessionV2, mappings: PersistedLastFmMappings) -> Self { + pub(super) fn new( + session: Option, + mappings: PersistedLastFmMappings, + ) -> Self { Self { version: REVIEW_TRANSACTION_VERSION, - session: Some(session), + session, sync_state: None, mappings, } @@ -133,7 +136,12 @@ impl ReviewTransactionStore { } pub(super) fn clear(&self) -> Result<(), String> { - match fs::remove_file(&self.path) { + durable_remove(&self.path) + .map_err(|_| "Could not finish the Last.fm review transaction.".to_string()) + } + + pub(super) fn sync_parent(&self) -> Result<(), String> { + match sync_parent(&self.path) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(_) => Err("Could not finish the Last.fm review transaction.".into()), diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 44fe438..ac3710c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod artwork_cache; mod backup; mod diagnostics; mod external_links; @@ -29,7 +30,6 @@ mod sync; mod sync_orchestrator; use std::{ - collections::HashMap, fs, path::PathBuf, sync::{ @@ -39,12 +39,11 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; +use artwork_cache::{LocalArtworkCache, MAX_LOCAL_ARTWORK_BYTES}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use library_state::LibraryState; #[cfg(test)] -use library_state::{ - commit_library_candidate, record_play_with as record_play, LibraryTransactionState, -}; +use library_state::{record_play_with as record_play, LibraryTransactionState}; #[cfg(test)] use playback::RepeatMode; @@ -77,8 +76,6 @@ use spotify_commands::{ SpotifyOpenTarget, }; #[cfg(test)] -use spotify_membership::album_track_uris; -#[cfg(test)] use store::SpotifyLibraryState; use store::{ FsArtistGenresStore, FsCooldownStore, FsOverlayStore, FsPlaylistStore, FsSettingsStore, @@ -93,7 +90,7 @@ use tauri::{ use tauri_plugin_opener::OpenerExt; pub(crate) type SharedTokenStore = Arc>>; -type SpotifyProvider = SpotifyClient; +type SpotifyProvider = SpotifyClient; use tauri_plugin_dialog::DialogExt; enum PlaybackDurableEffect { @@ -231,7 +228,7 @@ struct AppState { spotify_catalog_saved_generation: Arc, spotify_catalog_flush_gate: Arc>, spotify: Mutex>>, - artwork_cache: Mutex>>, + local_artwork_cache: LocalArtworkCache, playback: Arc, playback_effects: PlaybackEffects, lastfm: Arc, @@ -291,7 +288,7 @@ pub(crate) fn test_app_state( spotify_catalog_saved_generation: Arc::new(AtomicU64::new(0)), spotify_catalog_flush_gate: Arc::new(Mutex::new(())), spotify: Mutex::new(None), - artwork_cache: Mutex::default(), + local_artwork_cache: LocalArtworkCache::new(app_data_dir.join("artwork-cache")), playback: Arc::new(Playback::default()), playback_effects: PlaybackEffects::disabled(), lastfm, @@ -394,12 +391,12 @@ async fn disconnect_lastfm(app: tauri::AppHandle) -> Result Result<(), String> { - state - .token_store - .load() + let client = provider_from(state)?; + client + .load_tokens() + .await .map_err(|error| error.to_string())? .ok_or_else(|| "Connect to Spotify before enabling built-in playback.".to_string())?; - let client = provider_from(state)?; state .playback .switch_to_local(client.as_ref(), volume) @@ -584,24 +581,28 @@ fn album_id(uri: &str) -> Option<&str> { provider::spotify_id(uri, "album").ok() } -async fn resolve_track_artwork( - client: Option<&SpotifyClient>, - cache: &Mutex>>, +async fn resolve_track_artwork( + client: Option<&SpotifyClient>, + cache: &LocalArtworkCache, local_path: Result, String>, uri: &str, min_width: u32, ) -> Result, String> { - resolve_track_artwork_with(client, cache, local_path, uri, min_width, |path| { - retune_audio::read_artwork(path, MAX_LOCAL_ARTWORK_BYTES) - .map_err(|error| error.to_string()) + let local_cache = cache.clone(); + let local_uri = uri.to_owned(); + resolve_track_artwork_with(client, local_path, uri, min_width, move |path| { + local_cache + .get_or_load(&local_uri, &path, |path| { + retune_audio::read_artwork(path, MAX_LOCAL_ARTWORK_BYTES) + .map_err(|error| error.to_string()) + }) .and_then(|artwork| artwork.map(local_artwork_data_url).transpose()) }) .await } -async fn resolve_track_artwork_with( - client: Option<&SpotifyClient>, - cache: &Mutex>>, +async fn resolve_track_artwork_with( + client: Option<&SpotifyClient>, local_path: Result, String>, uri: &str, min_width: u32, @@ -609,7 +610,6 @@ async fn resolve_track_artwork_with( ) -> Result, String> where T: Transport, - S: TokenStore, F: FnOnce(PathBuf) -> Result, String> + Send + 'static, { let local_path = local_path?; @@ -617,15 +617,6 @@ where if local_path.is_none() && id.is_none() { return Ok(None); } - let cache_key = (uri.into(), min_width); - if let Some(cached) = cache - .lock() - .expect("artwork cache mutex poisoned") - .get(&cache_key) - .cloned() - { - return Ok(cached); - } let artwork = if let Some(path) = local_path { tauri::async_runtime::spawn_blocking(move || read_local(path)) .await @@ -639,30 +630,22 @@ where .and_then(|track| track.album) .and_then(|album| image_url_at_least(&album.images, min_width)) }; - let mut cache = cache.lock().expect("artwork cache mutex poisoned"); - if cache.len() >= 512 { - cache.clear(); - } - cache.insert(cache_key, artwork.clone()); Ok(artwork) } -const MAX_LOCAL_ARTWORK_BYTES: usize = 8 * 1024 * 1024; - fn local_artwork_data_url(artwork: retune_audio::Artwork) -> Result { if artwork.bytes.len() > MAX_LOCAL_ARTWORK_BYTES { return Err(format!( "Embedded artwork exceeds the {MAX_LOCAL_ARTWORK_BYTES}-byte limit." )); } - Ok(format!( - "data:{};base64,{}", - artwork - .mime - .as_deref() - .unwrap_or("application/octet-stream"), - BASE64_STANDARD.encode(artwork.bytes) - )) + let mime = artwork + .mime + .as_deref() + .unwrap_or("application/octet-stream"); + let mut data_url = format!("data:{mime};base64,"); + BASE64_STANDARD.encode_string(&artwork.bytes, &mut data_url); + Ok(data_url) } fn authorized_local_artwork_path(library: &Library, uri: &str) -> Result, String> { @@ -687,7 +670,7 @@ pub(crate) async fn publish_media_artwork(app: tauri::AppHandle, event: PlayerSt .flatten(); let Ok(Some(url)) = resolve_track_artwork( provider.as_deref(), - &state.artwork_cache, + &state.local_artwork_cache, Ok(local_path), event.uri.as_deref().unwrap_or_default(), 300, @@ -714,11 +697,6 @@ fn initial_library(debug: bool) -> Library { } } -#[cfg(test)] -fn remove_album_tracks(library: &mut Library, album: &Album) -> usize { - library.remove_uris(&album_track_uris(album)) -} - fn import_local_files(app: &tauri::AppHandle) { let handle = app.clone(); app.dialog() @@ -1194,7 +1172,7 @@ fn finish_startup( spotify_catalog_saved_generation, spotify_catalog_flush_gate, spotify: Mutex::new(spotify), - artwork_cache: Mutex::default(), + local_artwork_cache: LocalArtworkCache::new(app_data_dir.join("artwork-cache")), playback: Arc::clone(&playback), playback_effects, lastfm: Arc::clone(&lastfm), @@ -1588,6 +1566,11 @@ pub fn run() { tauri::RunEvent::Ready => ready = true, tauri::RunEvent::WindowEvent { label, event, .. } => { handle_local_drag_event(app, &label, &event); + if label == "lastfm-importer" && matches!(&event, tauri::WindowEvent::Destroyed) { + if let Some(state) = app.try_state::() { + state.lastfm_import.set_importer_window_open(false); + } + } } tauri::RunEvent::Resumed if ready => { let Some(state) = app.try_state::() else { @@ -2022,20 +2005,6 @@ mod tests { } } - struct RejectingOverlayStore; - - impl OverlayStore for RejectingOverlayStore { - fn load(&self) -> store::StoreResult> { - Ok(None) - } - - fn save(&self, _library: &Library) -> store::StoreResult<()> { - Err(store::StoreError::Io(std::io::Error::other( - "save rejected", - ))) - } - } - use retune_core::model::NewTrack; use retune_spotify::{ auth, @@ -2054,28 +2023,6 @@ mod tests { }; use crate::store::{BrowserPanes, LastFmScrobblingProfile}; - #[test] - fn failed_library_candidate_save_does_not_swap_live_memory() { - let mut live = Library::new(); - live.add(metadata_track( - "spotify:track:existing", - "Rock", - "Artist", - "Album", - )); - let before = retune_core::io::export_json(&live); - let mut candidate = live.clone(); - candidate.add(metadata_track( - "spotify:track:new", - "Rock", - "Artist", - "Album", - )); - - assert!(commit_library_candidate(&RejectingOverlayStore, &mut live, candidate).is_err()); - assert_eq!(retune_core::io::export_json(&live), before); - } - #[test] fn corrupt_token_quarantine_preserves_the_damaged_file() { for (use_dev_store, name) in [(true, "dev-tokens.json"), (false, "tokens.enc")] { @@ -2345,7 +2292,7 @@ mod tests { fn playlist_client( responses: impl IntoIterator, - ) -> SpotifyClient { + ) -> SpotifyClient { fake_client(responses, &auth::SCOPES) } @@ -2457,7 +2404,7 @@ mod tests { } #[tokio::test] - async fn track_artwork_resolves_requested_sizes_and_caches_each() { + async fn track_artwork_resolves_requested_sizes_from_spotify_catalog() { let track = serde_json::json!({ "uri": "spotify:track:track", "name": "Track", @@ -2479,53 +2426,88 @@ mod tests { serde_json::json!({"uri": "spotify:track:missing", "name": "Missing"}), ), ]); - let cache = Mutex::default(); - + let local_cache = LocalArtworkCache::new("unused"); assert_eq!(track_id("spotify:track:track"), Some("track")); assert_eq!(track_id("spotify:album:album"), None); assert_eq!(track_id("spotify:track:"), None); assert_eq!(track_id("spotify:track:../albums"), None); assert_eq!( - resolve_track_artwork(Some(&client), &cache, Ok(None), "spotify:track:track", 64) - .await - .unwrap() - .as_deref(), + resolve_track_artwork( + Some(&client), + &local_cache, + Ok(None), + "spotify:track:track", + 64 + ) + .await + .unwrap() + .as_deref(), Some("small") ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, Ok(None), "spotify:track:track", 64) - .await - .unwrap(), + resolve_track_artwork( + Some(&client), + &local_cache, + Ok(None), + "spotify:track:track", + 64 + ) + .await + .unwrap(), Some("small".into()) ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, Ok(None), "spotify:track:track", 300) - .await - .unwrap(), + resolve_track_artwork( + Some(&client), + &local_cache, + Ok(None), + "spotify:track:track", + 300 + ) + .await + .unwrap(), Some("large".into()) ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, Ok(None), "spotify:album:album", 64) - .await - .unwrap(), + resolve_track_artwork( + Some(&client), + &local_cache, + Ok(None), + "spotify:album:album", + 64 + ) + .await + .unwrap(), None ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, Ok(None), "spotify:track:missing", 64) - .await - .unwrap(), + resolve_track_artwork( + Some(&client), + &local_cache, + Ok(None), + "spotify:track:missing", + 64 + ) + .await + .unwrap(), None ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, Ok(None), "spotify:track:missing", 64) - .await - .unwrap(), + resolve_track_artwork( + Some(&client), + &local_cache, + Ok(None), + "spotify:track:missing", + 64 + ) + .await + .unwrap(), None ); assert_eq!( resolve_track_artwork( Some(&client), - &cache, + &local_cache, Ok(None), "spotify:track:../albums", 64 @@ -2538,9 +2520,9 @@ mod tests { } #[tokio::test] - async fn local_track_artwork_reads_tags_and_caches_missing_files() { - let cache = Mutex::default(); + async fn local_track_artwork_uses_disk_cache_across_requested_sizes() { let dir = tempfile::tempdir().unwrap(); + let cache = LocalArtworkCache::new(dir.path().join("artwork-cache")); let tagged = dir.path().join("tagged.mp3"); let tagged_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../../crates/retune-audio/tests/fixtures/cc0-audio-tagged.mp3"); @@ -2548,7 +2530,7 @@ mod tests { let uri = localfiles::file_uri(&tagged.canonicalize().unwrap()); let artwork = resolve_track_artwork( - None::<&SpotifyClient>, + None::<&SpotifyClient>, &cache, Ok(Some(tagged.clone())), &uri, @@ -2567,14 +2549,13 @@ mod tests { .unwrap() .bytes ); - fs::remove_file(&tagged).unwrap(); assert_eq!( resolve_track_artwork( - None::<&SpotifyClient>, + None::<&SpotifyClient>, &cache, Ok(Some(tagged.clone())), &uri, - 64 + 300 ) .await .unwrap(), @@ -2585,7 +2566,7 @@ mod tests { .join("../../../crates/retune-audio/tests/fixtures/cc0-audio.wav"); assert_eq!( resolve_track_artwork( - None::<&SpotifyClient>, + None::<&SpotifyClient>, &cache, Ok(Some(wav.clone())), &localfiles::file_uri(&wav), @@ -2599,16 +2580,13 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn slow_local_artwork_read_does_not_block_the_async_worker() { - let cache = Arc::new(Mutex::default()); let started = Arc::new(tokio::sync::Notify::new()); let (release, released) = std::sync::mpsc::channel(); let resolver = { - let cache = Arc::clone(&cache); let started = Arc::clone(&started); tokio::spawn(async move { resolve_track_artwork_with( - None::<&SpotifyClient>, - &cache, + None::<&SpotifyClient>, Ok(Some(PathBuf::from("slow.mp3"))), "file:///slow.mp3", 64, @@ -2646,52 +2624,43 @@ mod tests { #[tokio::test] async fn rejected_local_artwork_never_reads_tags() { - let cache = Mutex::default(); - let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let reader_reads = Arc::clone(&reads); + let dir = tempfile::tempdir().unwrap(); + let cache_dir = dir.path().join("artwork-cache"); + let cache = LocalArtworkCache::new(&cache_dir); - let result = resolve_track_artwork_with( - None::<&SpotifyClient>, + let result = resolve_track_artwork( + None::<&SpotifyClient>, &cache, Err("Local artwork resource is not in the library.".into()), "file:///etc/passwd", 64, - move |_| { - reader_reads.fetch_add(1, Ordering::SeqCst); - Ok(None) - }, ) .await; assert!(result.is_err()); - assert_eq!(reads.load(Ordering::SeqCst), 0); - assert!(cache.lock().unwrap().is_empty()); + assert!(!cache_dir.exists()); } - #[tokio::test] - async fn local_artwork_errors_are_not_cached() { - let cache = Mutex::default(); + #[test] + fn local_artwork_errors_are_not_cached() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("oversized.mp3"); + fs::write(&source, b"audio source").unwrap(); + let cache_dir = dir.path().join("artwork-cache"); + let cache = LocalArtworkCache::new(&cache_dir); let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); for _ in 0..2 { let reader_reads = Arc::clone(&reads); - let result = resolve_track_artwork_with( - None::<&SpotifyClient>, - &cache, - Ok(Some(PathBuf::from("oversized.mp3"))), - "file:///oversized.mp3", - 64, - move |_| { - reader_reads.fetch_add(1, Ordering::SeqCst); - Err("embedded artwork exceeds the byte limit".into()) - }, - ) - .await; + let result = cache.get_or_load("file:///oversized.mp3", &source, move |_| { + reader_reads.fetch_add(1, Ordering::SeqCst); + Err("embedded artwork exceeds the byte limit".into()) + }); assert!(result.is_err()); } assert_eq!(reads.load(Ordering::SeqCst), 2); - assert!(cache.lock().unwrap().is_empty()); + assert!(!cache_dir.exists()); } #[test] @@ -2975,39 +2944,6 @@ mod tests { assert!(!tracks[1].in_library); } - #[test] - fn remove_album_tracks_removes_exactly_the_album_uris() { - let mut library = Library::new(); - library.add(metadata_track( - "spotify:track:one", - "Rock", - "Artist", - "Album", - )); - library.add(metadata_track( - "spotify:track:two", - "Rock", - "Artist", - "Album", - )); - library.add(metadata_track( - "spotify:track:other", - "Rock", - "Artist", - "Other", - )); - - assert_eq!(remove_album_tracks(&mut library, &spotify_album()), 2); - assert_eq!( - library - .tracks() - .iter() - .map(|track| track.uri.as_str()) - .collect::>(), - ["spotify:track:other"] - ); - } - #[test] fn sync_progress_is_section_weighted_and_never_moves_backwards() { let mut progress = SyncProgressState::default(); diff --git a/apps/desktop/src-tauri/src/library_state.rs b/apps/desktop/src-tauri/src/library_state.rs index 70c99ce..e3485d1 100644 --- a/apps/desktop/src-tauri/src/library_state.rs +++ b/apps/desktop/src-tauri/src/library_state.rs @@ -133,8 +133,12 @@ impl LibraryState { self.restore_mutations.ensure_allowed()?; let mut next = self.current.lock().expect("library mutex poisoned").clone(); let value = mutation(&mut next)?; - self.store.save(&next).map_err(|error| error.to_string())?; - *self.current.lock().expect("library mutex poisoned") = next; + save_library_candidate( + self.store.as_ref(), + &self.current, + next, + &self.restore_mutations, + )?; drop(write_gate); Ok(value) } @@ -153,8 +157,7 @@ impl LibraryState { tauri::async_runtime::spawn_blocking(move || { let _write_gate = write_gate.lock().expect("library write gate poisoned"); restore_mutations.ensure_allowed()?; - store.save(&next).map_err(|error| error.to_string())?; - *current.lock().expect("library mutex poisoned") = next; + save_library_candidate(store.as_ref(), ¤t, next, &restore_mutations)?; Ok::<_, String>((transaction, owner)) }) .await @@ -216,8 +219,12 @@ impl LibraryOwner { if *self.library.lock().expect("library mutex poisoned") == next { return Ok(value); } - self.store.save(&next).map_err(|error| error.to_string())?; - *self.library.lock().expect("library mutex poisoned") = next; + save_library_candidate( + self.store.as_ref(), + &self.library, + next, + &self.restore_mutations, + )?; drop(write_gate); Ok(value) } @@ -251,8 +258,7 @@ impl LibraryOwner { if *library.lock().expect("library mutex poisoned") == next { return Ok((value, owner)); } - store.save(&next).map_err(|error| error.to_string())?; - *library.lock().expect("library mutex poisoned") = next; + save_library_candidate(store.as_ref(), &library, next, &restore_mutations)?; Ok::<_, String>((value, owner)) }) .await @@ -273,12 +279,12 @@ impl LibraryRestore<'_> { } pub(crate) fn replace(&self, next: Library) -> Result<(), String> { - self.state - .store - .save(&next) - .map_err(|error| error.to_string())?; - *self.state.current.lock().expect("library mutex poisoned") = next; - Ok(()) + save_library_candidate( + self.state.store.as_ref(), + &self.state.current, + next, + &self.state.restore_mutations, + ) } pub(crate) fn install_recovered(&self, next: Library) { @@ -319,15 +325,40 @@ fn wait_for_transaction(state: &LibraryTransactionState) -> Result, next: Library, + restore_mutations: &RestoreMutationState, ) -> Result<(), String> { - store.save(&next).map_err(|error| error.to_string())?; - *current = next; - Ok(()) + let existed_before = match store.exists() { + Ok(existed) => existed, + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(error.to_string()); + } + }; + match store.save(&next) { + Ok(()) => { + *current.lock().expect("library mutex poisoned") = next; + Ok(()) + } + Err(error) => { + let message = error.to_string(); + let before = current.lock().expect("library mutex poisoned").clone(); + match store.load() { + Ok(Some(actual)) if actual == next => { + *current.lock().expect("library mutex poisoned") = actual; + } + Ok(Some(actual)) if actual == before => {} + Ok(None) if !existed_before => {} + Err(crate::store::StoreError::Io(error)) + if error.kind() == std::io::ErrorKind::IsADirectory => {} + _ => restore_mutations.mark_recovery_required(), + } + Err(message) + } + } } pub(crate) fn record_play_with( @@ -346,8 +377,7 @@ pub(crate) fn record_play_with( if !next.record_play(uri, played_at) { return Ok(false); } - store.save(&next).map_err(|error| error.to_string())?; - *library.lock().expect("library mutex poisoned") = next; + save_library_candidate(store, library, next, restore_mutations)?; Ok(true) } @@ -375,6 +405,54 @@ mod tests { .is_none()); } + #[test] + fn failed_owner_save_does_not_swap_live_memory() { + let directory = tempfile::tempdir().unwrap(); + let state = LibraryState::new(Library::new(), FsOverlayStore::new(directory.path())); + let hook = crate::store::SaveHook::new(true); + state.arm_save(Arc::clone(&hook)); + let owner = state.owner(); + let worker = std::thread::spawn(move || { + owner.mutate(|library| { + library.add(NewTrack { + uri: "file:///rejected.mp3".into(), + ..NewTrack::default() + }); + Ok(()) + }) + }); + hook.wait_until_reached(); + hook.release(); + + assert!(worker.join().unwrap().is_err()); + assert!(state.snapshot().tracks().is_empty()); + } + + #[test] + fn owner_reconciles_after_parent_sync_failure() { + let directory = tempfile::tempdir().unwrap(); + let store = FsOverlayStore::new(directory.path()); + let state = LibraryState::new(Library::new(), store.clone()); + let path = directory.path().join("library.json"); + crate::persistence::fail_next_atomic_write( + &path, + crate::persistence::FailureStage::ParentSync, + ); + + let error = state + .mutate(|library| { + library.add(NewTrack { + uri: "file:///replacement.mp3".into(), + ..NewTrack::default() + }); + Ok(()) + }) + .unwrap_err(); + assert!(error.contains("injected atomic-write failure")); + assert_eq!(state.snapshot().tracks().len(), 1); + assert_eq!(store.load().unwrap(), Some(state.snapshot())); + } + #[tokio::test(flavor = "current_thread")] async fn transaction_save_does_not_block_tokio_and_publishes_atomically() { let directory = tempfile::tempdir().unwrap(); diff --git a/apps/desktop/src-tauri/src/persistence.rs b/apps/desktop/src-tauri/src/persistence.rs index 19add16..71ee64b 100644 --- a/apps/desktop/src-tauri/src/persistence.rs +++ b/apps/desktop/src-tauri/src/persistence.rs @@ -5,11 +5,17 @@ use std::{ sync::atomic::{AtomicU64, Ordering}, }; +#[cfg(test)] +use std::sync::Mutex; + #[cfg(unix)] use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); +#[cfg(test)] +static FAIL_ATOMIC_WRITES: Mutex> = Mutex::new(Vec::new()); + #[cfg(windows)] static ATOMIC_REPLACE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -57,7 +63,7 @@ fn atomic_write_inner( .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "store path has no parent"))?; fs::create_dir_all(parent)?; #[cfg(test)] - fail_at(hooks, FailureStage::Create)?; + fail_at(path, hooks, FailureStage::Create)?; let (temporary, mut file) = create_temporary(path, mode)?; #[cfg(test)] if let Some(created) = hooks.and_then(|hooks| hooks.created) { @@ -65,15 +71,18 @@ fn atomic_write_inner( } let result = (|| { #[cfg(test)] - fail_at(hooks, FailureStage::Write)?; + fail_at(path, hooks, FailureStage::Write)?; file.write_all(bytes)?; #[cfg(test)] - fail_at(hooks, FailureStage::Sync)?; + fail_at(path, hooks, FailureStage::Sync)?; file.sync_all()?; drop(file); #[cfg(test)] - fail_at(hooks, FailureStage::Rename)?; + fail_at(path, hooks, FailureStage::Rename)?; replace(&temporary, path)?; + #[cfg(test)] + fail_at(path, hooks, FailureStage::ParentSync)?; + sync_parent(path)?; Ok(()) })(); if result.is_err() { @@ -89,6 +98,7 @@ pub(crate) enum FailureStage { Write, Sync, Rename, + ParentSync, } #[cfg(test)] @@ -109,6 +119,58 @@ pub(crate) fn atomic_write_with_failure( ) } +/// Flush the directory entry containing `path` after a rename or unlink. +/// +/// Unix filesystems need this second barrier to make the replacement or +/// removal durable. Windows replacement already uses MOVEFILE_WRITE_THROUGH, +/// and directory handles are not opened here on non-Unix platforms. +pub(crate) fn sync_parent(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + let parent = path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "store path has no parent") + })?; + File::open(parent)?.sync_all() + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +#[cfg(test)] +pub(crate) fn fail_next_atomic_write(path: &Path, failure: FailureStage) { + fail_atomic_writes(path, failure, 1); +} + +#[cfg(test)] +pub(crate) fn fail_atomic_writes(path: &Path, failure: FailureStage, count: usize) { + if count != 0 { + FAIL_ATOMIC_WRITES + .lock() + .unwrap() + .push((path.to_owned(), failure, count)); + } +} + +/// Remove a file and flush its containing directory. +/// +/// An already absent file still needs the directory barrier: this is the +/// retry path after an unlink succeeded but its first barrier failed. If the +/// directory itself is gone, there is no deletion left to make durable. +pub(crate) fn durable_remove(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => sync_parent(path), + Err(error) if error.kind() == io::ErrorKind::NotFound => match sync_parent(path) { + Ok(()) => Ok(()), + Err(sync_error) if sync_error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(sync_error) => Err(sync_error), + }, + Err(error) => Err(error), + } +} + #[cfg(test)] struct TestHooks<'a> { failure: Option, @@ -116,11 +178,24 @@ struct TestHooks<'a> { } #[cfg(test)] -fn fail_at(hooks: Option<&TestHooks<'_>>, stage: FailureStage) -> io::Result<()> { +fn fail_at(path: &Path, hooks: Option<&TestHooks<'_>>, stage: FailureStage) -> io::Result<()> { if hooks.and_then(|hooks| hooks.failure) == Some(stage) { Err(io::Error::other("injected atomic-write failure")) } else { - Ok(()) + let mut failures = FAIL_ATOMIC_WRITES.lock().unwrap(); + let Some(index) = failures + .iter() + .position(|(expected_path, expected_stage, remaining)| { + expected_path == path && *expected_stage == stage && *remaining != 0 + }) + else { + return Ok(()); + }; + failures[index].2 -= 1; + if failures[index].2 == 0 { + failures.remove(index); + } + Err(io::Error::other("injected atomic-write failure")) } } @@ -274,6 +349,38 @@ mod tests { } } + #[test] + fn parent_sync_failure_reports_error_after_replacement() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.json"); + fs::write(&path, b"previous").unwrap(); + + assert!(atomic_write_inner( + &path, + b"replacement", + None, + Some(&TestHooks { + failure: Some(FailureStage::ParentSync), + created: None, + }), + ) + .is_err()); + + assert_eq!(fs::read(&path).unwrap(), b"replacement"); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); + } + + #[test] + fn durable_remove_is_idempotent() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("journal.json"); + fs::write(&path, b"journal").unwrap(); + + durable_remove(&path).unwrap(); + durable_remove(&path).unwrap(); + assert!(!path.exists()); + } + #[test] fn limited_reads_accept_the_limit_and_reject_larger_and_sparse_files() { let directory = tempfile::tempdir().unwrap(); diff --git a/apps/desktop/src-tauri/src/playback/connect.rs b/apps/desktop/src-tauri/src/playback/connect.rs index e65f413..694ded8 100644 --- a/apps/desktop/src-tauri/src/playback/connect.rs +++ b/apps/desktop/src-tauri/src/playback/connect.rs @@ -1,9 +1,6 @@ use std::sync::{Arc, Once}; -use retune_spotify::{ - client::{Device, PlayerState, SpotifyClient, Transport}, - tokens::TokenStore, -}; +use retune_spotify::client::{Device, PlayerState, SpotifyClient, Transport}; use tokio::sync::mpsc; use super::{LiveClient, NeutralEvent, NeutralState, RepeatMode, Snapshot, SnapshotTrack}; @@ -146,9 +143,9 @@ impl ConnectBackend { Ok(()) } - async fn begin( + async fn begin( &self, - client: &SpotifyClient, + client: &SpotifyClient, snapshot: Snapshot, repeat: RepeatMode, ) -> Result, String> { @@ -207,26 +204,26 @@ impl ConnectBackend { Ok(Some((event, generation))) } - async fn toggle_pause( + async fn toggle_pause( &self, - client: &SpotifyClient, + client: &SpotifyClient, ) -> Result { self.set_context_playing(client, None) .await? .ok_or_else(|| "Nothing to toggle".into()) } - async fn set_playing_state( + async fn set_playing_state( &self, - client: &SpotifyClient, + client: &SpotifyClient, playing: bool, ) -> Result, String> { self.set_context_playing(client, Some(playing)).await } - async fn set_context_playing( + async fn set_context_playing( &self, - client: &SpotifyClient, + client: &SpotifyClient, requested: Option, ) -> Result, String> { let (revision, generation, device_id, playing) = { @@ -269,9 +266,9 @@ impl ConnectBackend { ))) } - async fn seek_state( + async fn seek_state( &self, - client: &SpotifyClient, + client: &SpotifyClient, seconds: u64, ) -> Result, String> { let (revision, generation, device_id, seconds) = { @@ -309,9 +306,9 @@ impl ConnectBackend { ))) } - async fn step_state( + async fn step_state( &self, - client: &SpotifyClient, + client: &SpotifyClient, direction: i8, ) -> Result, String> { let (revision, generation, device_id, snapshot, next) = { @@ -372,9 +369,9 @@ impl ConnectBackend { ))) } - pub(super) async fn set_volume( + pub(super) async fn set_volume( &self, - client: &SpotifyClient, + client: &SpotifyClient, volume: u8, ) -> Result<(), String> { let (revision, device_id) = { @@ -395,9 +392,9 @@ impl ConnectBackend { .map_err(|error| error.to_string()) } - pub(super) async fn set_repeat_state( + pub(super) async fn set_repeat_state( &self, - client: &SpotifyClient, + client: &SpotifyClient, repeat: RepeatMode, ) -> Result<(), String> { let (revision, generation, device_id, route_at_end, wrap) = { @@ -533,11 +530,7 @@ impl ConnectBackend { }); } - async fn poll( - self, - client: Arc>, - generation: u64, - ) { + async fn poll(self, client: Arc>, generation: u64) { let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); interval.tick().await; loop { @@ -800,8 +793,8 @@ fn queue_request(tracks: &[SnapshotTrack], index: usize) -> (Vec, usize) (uris, index - start) } -async fn play_snapshot( - client: &SpotifyClient, +async fn play_snapshot( + client: &SpotifyClient, device_id: &str, tracks: &[SnapshotTrack], index: usize, @@ -936,7 +929,7 @@ mod tests { responses: impl IntoIterator, releases: Vec>, entered: mpsc::UnboundedSender, - ) -> SpotifyClient { + ) -> SpotifyClient { SpotifyClient::new( "client", DelayedTransport { diff --git a/apps/desktop/src-tauri/src/playback/local.rs b/apps/desktop/src-tauri/src/playback/local.rs index 945ff53..c81e3f3 100644 --- a/apps/desktop/src-tauri/src/playback/local.rs +++ b/apps/desktop/src-tauri/src/playback/local.rs @@ -16,7 +16,6 @@ use librespot_playback::{ player::{Player, PlayerEvent}, }; use librespot_protocol::authentication::AuthenticationType; -use retune_spotify::tokens::TokenStore; use tokio::sync::mpsc; use super::{ @@ -62,7 +61,7 @@ impl LocalBackend { cache_dir: Option<&Path>, audio: AudioSettings, ) -> Result { - let credentials = stored_credentials(client)?; + let credentials = stored_credentials(client).await?; // Read farther ahead to survive short network stalls. let _ = AudioFetchParams::set(AudioFetchParams { read_ahead_during_playback: Duration::from_secs(30), @@ -70,13 +69,12 @@ impl LocalBackend { }); let cache = cache_dir.and_then(audio_cache); let session = Session::new(SessionConfig::default(), cache); - session - .connect(credentials, false) - .await - .map_err(|error| session_error(client, error))?; + if let Err(error) = session.connect(credentials, false).await { + return Err(session_error(client, error).await); + } if let Err(error) = session.login5().auth_token().await { session.shutdown(); - return Err(session_error(client, error)); + return Err(session_error(client, error).await); } let mixer = mixer::find(None) @@ -142,7 +140,7 @@ impl LocalBackend { &mut self, client: &LiveClient, ) -> Result<(), PlaybackError> { - let credentials = stored_credentials(client)?; + let credentials = stored_credentials(client).await?; let (config, cache) = { let runtime = self .runtime @@ -154,13 +152,12 @@ impl LocalBackend { ) }; let session = Session::new(config, cache); - session - .connect(credentials, false) - .await - .map_err(|error| session_error(client, error))?; + if let Err(error) = session.connect(credentials, false).await { + return Err(session_error(client, error).await); + } if let Err(error) = session.login5().auth_token().await { session.shutdown(); - return Err(session_error(client, error)); + return Err(session_error(client, error).await); } let runtime = self .runtime @@ -182,13 +179,10 @@ impl LocalBackend { .runtime .as_ref() .ok_or_else(|| PlaybackError::message("Local playback is unavailable"))?; - runtime - .session - .login5() - .auth_token() - .await - .map(|_| ()) - .map_err(|error| session_error(client, error)) + match runtime.session.login5().auth_token().await { + Ok(_) => Ok(()), + Err(error) => Err(session_error(client, error).await), + } } pub(super) fn play( @@ -301,10 +295,10 @@ fn bitrate(value: u16) -> Bitrate { } } -fn stored_credentials(client: &LiveClient) -> Result { +async fn stored_credentials(client: &LiveClient) -> Result { let tokens = client - .token_store() - .load() + .load_tokens() + .await .map_err(|error| PlaybackError::message(error.to_string()))?; let credentials = tokens .and_then(|tokens| tokens.playback_credentials) @@ -317,7 +311,7 @@ fn stored_credentials(client: &LiveClient) -> Result }) } -fn session_error(client: &LiveClient, error: LibrespotError) -> PlaybackError { +async fn session_error(client: &LiveClient, error: LibrespotError) -> PlaybackError { if !matches!( error.kind, ErrorKind::PermissionDenied | ErrorKind::Unauthenticated | ErrorKind::FailedPrecondition @@ -328,15 +322,15 @@ fn session_error(client: &LiveClient, error: LibrespotError) -> PlaybackError { "Spotify playback authorization rejected during session verification; clearing stored credential secret (kind={:?}, error={error:?})", error.kind ); - match client.token_store().load() { + match client.load_tokens().await { Ok(Some(current)) => { let mut cleared = current.clone(); if let Some(credentials) = cleared.playback_credentials.as_mut() { credentials.auth_data.clear(); } match client - .token_store() - .replace_if_current(¤t, &cleared) + .replace_tokens_if_current(¤t, &cleared) + .await { Ok(_) => PlaybackError::authorization(PlaybackAuthorizationReason::Rejected), Err(clear_error) => PlaybackError::message(format!( @@ -523,7 +517,7 @@ mod tests { use super::*; use retune_spotify::{ client::{HttpTransport, SpotifyClient}, - tokens::{CachedTokenStore, InMemoryTokenStore, PlaybackCredentials, Tokens}, + tokens::{CachedTokenStore, InMemoryTokenStore, PlaybackCredentials, TokenStore, Tokens}, }; use std::sync::Mutex; @@ -609,8 +603,8 @@ mod tests { assert_eq!(bitrate(0), Bitrate::Bitrate320); } - #[test] - fn semantic_playback_rejection_retains_verified_username() { + #[tokio::test] + async fn semantic_playback_rejection_retains_verified_username() { let tokens: Box = Box::new(InMemoryTokenStore::new(Some(Tokens { access: "web-access".into(), refresh: "web-refresh".into(), @@ -629,7 +623,7 @@ mod tests { ); assert!(matches!( - session_error(&client, error), + session_error(&client, error).await, PlaybackError::AuthorizationRequired { reason: PlaybackAuthorizationReason::Rejected, .. @@ -643,8 +637,8 @@ mod tests { assert!(playback.auth_data.is_empty()); } - #[test] - fn transient_session_error_keeps_playback_credentials() { + #[tokio::test] + async fn transient_session_error_keeps_playback_credentials() { let tokens: Box = Box::new(InMemoryTokenStore::new(Some(Tokens { access: "web-access".into(), refresh: "web-refresh".into(), @@ -663,7 +657,7 @@ mod tests { ); assert!(matches!( - session_error(&client, error), + session_error(&client, error).await, PlaybackError::Message(_) )); assert!(store @@ -674,8 +668,8 @@ mod tests { .is_some()); } - #[test] - fn semantic_rejection_does_not_overwrite_concurrently_refreshed_tokens() { + #[tokio::test] + async fn semantic_rejection_does_not_overwrite_concurrently_refreshed_tokens() { let old = Tokens { access: "old-access".into(), refresh: "old-refresh".into(), @@ -709,7 +703,7 @@ mod tests { std::io::Error::other("rejected"), ); assert!(matches!( - session_error(&client, error), + session_error(&client, error).await, PlaybackError::AuthorizationRequired { .. } )); assert_eq!(store.load().unwrap(), Some(replacement)); diff --git a/apps/desktop/src-tauri/src/playback/mod.rs b/apps/desktop/src-tauri/src/playback/mod.rs index e636999..aaece82 100644 --- a/apps/desktop/src-tauri/src/playback/mod.rs +++ b/apps/desktop/src-tauri/src/playback/mod.rs @@ -21,7 +21,7 @@ use retune_spotify::client::{HttpTransport, SpotifyClient}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -type LiveClient = SpotifyClient; +type LiveClient = SpotifyClient; type ProviderResolver = dyn Fn() -> Result, String> + Send + Sync; type EffectSink = dyn Fn(PlaybackEffect) + Send + Sync; diff --git a/apps/desktop/src-tauri/src/playback_commands.rs b/apps/desktop/src-tauri/src/playback_commands.rs index b19f5df..4aeb731 100644 --- a/apps/desktop/src-tauri/src/playback_commands.rs +++ b/apps/desktop/src-tauri/src/playback_commands.rs @@ -2,7 +2,6 @@ use retune_core::model::Library; use retune_spotify::{ catalog::SpotifyCatalog, client::{SpotifyClient, Transport}, - tokens::TokenStore, }; use tauri::Manager; @@ -132,13 +131,13 @@ pub(super) async fn play_tracks( Ok(outcome) } -async fn resolve_resources( +async fn resolve_resources( resources: Vec, start_index: usize, library: Library, playlists: crate::playlists::PlaylistCache, catalog: SpotifyCatalog, - client: Option<&SpotifyClient>, + client: Option<&SpotifyClient>, ) -> Result<(Vec, usize), String> { let (resources, mut resolved, enabled) = tauri::async_runtime::spawn_blocking(move || { let (resolved, enabled) = playback_resources::resolve_cached( diff --git a/apps/desktop/src-tauri/src/playlist_commands.rs b/apps/desktop/src-tauri/src/playlist_commands.rs index 312dd80..626b0cb 100644 --- a/apps/desktop/src-tauri/src/playlist_commands.rs +++ b/apps/desktop/src-tauri/src/playlist_commands.rs @@ -4,7 +4,7 @@ use std::{ }; use retune_core::model::Library; -use retune_spotify::tokens::{TokenStore, Tokens}; +use retune_spotify::tokens::Tokens; use serde::Serialize; use tauri::Manager; @@ -126,15 +126,9 @@ pub(super) async fn playlist_unfollow(app: tauri::AppHandle, id: String) -> Resu let (mut operation, mut cache) = state.playlists.begin_mutation().await?; operation.remote_started(); if let Err(error) = playlists::unfollow(client.as_ref(), &mut cache, &id).await { - return Err(reconcile_playlist_mutation( - &app, - &state, - client.as_ref(), - operation, - &cache, - error, - ) - .await); + return Err( + reconcile_playlist_mutation(&app, client.as_ref(), operation, &cache, error).await, + ); } operation.remote_resolved(); save_playlists(&app, operation, cache, true).await @@ -379,7 +373,7 @@ pub(super) async fn sync_playlists( let synced = match sync_result { Ok(synced) => synced, Err(error) => { - let tokens = state.token_store.load().ok().flatten(); + let tokens = client.load_tokens().await.ok().flatten(); if let Some(error) = dispatch_playlist_error( &state.playlist_reauth_notified, error, @@ -394,24 +388,26 @@ pub(super) async fn sync_playlists( save_playlists(app, operation, synced, false).await } -fn playlist_error(state: &AppState, error: retune_spotify::Error) -> String { - let tokens = state.token_store.load().ok().flatten(); +async fn playlist_error( + client: &retune_spotify::client::SpotifyClient, + error: retune_spotify::Error, +) -> String { + let tokens = client.load_tokens().await.ok().flatten(); match playlists::classify_error(error, tokens.as_ref()) { playlists::PlaylistFailure::ReconnectRequired => playlists::RECONNECT_HINT.into(), playlists::PlaylistFailure::Spotify(error) => error.to_string(), } } -async fn reconcile_playlist_mutation( +async fn reconcile_playlist_mutation( app: &tauri::AppHandle, - state: &AppState, - client: &retune_spotify::client::SpotifyClient, + client: &retune_spotify::client::SpotifyClient, mut operation: crate::playlist_state::PlaylistOperation, cache: &playlists::PlaylistCache, error: retune_spotify::Error, ) -> String { let ambiguous = matches!(&error, retune_spotify::Error::AmbiguousMutation { .. }); - let message = playlist_error(state, error); + let message = playlist_error(client, error).await; if ambiguous { match playlists::sync(client, cache).await { Ok(reconciled) => { @@ -456,8 +452,7 @@ async fn playlist_add_inner( "Only your playlists can be changed.".into() } playlists::PlaylistAddError::Spotify(error) => { - reconcile_playlist_mutation(app, &state, client.as_ref(), operation, &cache, error) - .await + reconcile_playlist_mutation(app, client.as_ref(), operation, &cache, error).await } }); } @@ -511,15 +506,9 @@ pub(super) async fn playlist_create( let client = provider_from(&state)?; operation.remote_started(); if let Err(error) = playlists::create(client.as_ref(), &mut cache, name).await { - return Err(reconcile_playlist_mutation( - &app, - &state, - client.as_ref(), - operation, - &cache, - error, - ) - .await); + return Err( + reconcile_playlist_mutation(&app, client.as_ref(), operation, &cache, error).await, + ); } operation.remote_resolved(); let created = playlist_list_views(&cache, &[]) @@ -578,12 +567,12 @@ pub(super) async fn playlist_reorder( range_length, ) .await; - finish_playlist_mutation(&app, &state, operation, cache, result).await + finish_playlist_mutation(&app, client.as_ref(), operation, cache, result).await } -async fn finish_playlist_mutation( +async fn finish_playlist_mutation( app: &tauri::AppHandle, - state: &AppState, + client: &retune_spotify::client::SpotifyClient, mut operation: crate::playlist_state::PlaylistOperation, cache: playlists::PlaylistCache, result: Result<(), playlists::PlaylistMutationError>, @@ -600,7 +589,7 @@ async fn finish_playlist_mutation( } Err(playlists::PlaylistMutationError::Spotify(error)) => { operation.remote_resolved(); - Err(playlist_error(state, error)) + Err(playlist_error(client, error).await) } Err(playlists::PlaylistMutationError::Other(error)) => { operation.remote_resolved(); @@ -622,5 +611,5 @@ pub(super) async fn playlist_remove( let (mut operation, mut cache) = state.playlists.begin_mutation().await?; operation.remote_started(); let result = playlists::remove(client.as_ref(), &mut cache, &id, &indices).await; - finish_playlist_mutation(&app, &state, operation, cache, result).await + finish_playlist_mutation(&app, client.as_ref(), operation, cache, result).await } diff --git a/apps/desktop/src-tauri/src/playlist_state.rs b/apps/desktop/src-tauri/src/playlist_state.rs index c90a998..c19d5fa 100644 --- a/apps/desktop/src-tauri/src/playlist_state.rs +++ b/apps/desktop/src-tauri/src/playlist_state.rs @@ -6,6 +6,49 @@ use crate::{ playlists::PlaylistCache, restore_latch::RestoreMutationState, store::FsPlaylistStore, }; +fn save_playlist_candidate( + store: &FsPlaylistStore, + current: &Mutex, + next: PlaylistCache, + restore_mutations: &RestoreMutationState, +) -> Result<(), String> { + let existed_before = match store.exists() { + Ok(existed) => existed, + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(error.to_string()); + } + }; + match store.save(&next) { + Ok(()) => { + *current.lock().expect("playlist mutex poisoned") = CurrentPlaylistCache { + cache: next, + authoritative: true, + }; + Ok(()) + } + Err(error) => { + let message = error.to_string(); + let disk = match store.exists() { + Ok(false) if !existed_before => Ok(None), + Ok(true) => store.load().map(Some).map_err(|error| error.to_string()), + Ok(false) => Err("playlist file disappeared after save".to_string()), + Err(error) => Err(error.to_string()), + }; + let mut state = current.lock().expect("playlist mutex poisoned"); + let before = state.cache.clone(); + state.authoritative = false; + match disk { + Ok(None) => {} + Ok(Some(actual)) if actual == next => state.cache = next, + Ok(Some(actual)) if actual == before => {} + _ => restore_mutations.mark_recovery_required(), + } + Err(message) + } + } +} + pub(crate) struct PlaylistState { current: Arc>, mutation_gate: Arc>, @@ -127,34 +170,27 @@ impl PlaylistState { &self, mut operation: PlaylistOperation, next: PlaylistCache, - invalidate_on_failure: bool, + _invalidate_on_failure: bool, ) -> Result { self.restore_mutations.ensure_allowed()?; let store = self.store.clone(); let current = Arc::clone(&self.current); + let current_for_save = Arc::clone(¤t); + let restore_mutations = Arc::clone(&self.restore_mutations); let completion = tauri::async_runtime::spawn(async move { - let result = - tauri::async_runtime::spawn_blocking(move || store.save(&next).map(|()| next)) - .await - .map_err(|error| error.to_string())? - .map_err(|error| error.to_string()); - let next = match result { - Ok(next) => next, - Err(error) => { - if invalidate_on_failure || operation.remote_outcome_uncertain { - current - .lock() - .expect("playlist mutex poisoned") - .authoritative = false; - } - operation.remote_resolved(); - return Err(error); - } - }; - *current.lock().expect("playlist mutex poisoned") = CurrentPlaylistCache { - cache: next, - authoritative: true, - }; + let result = tauri::async_runtime::spawn_blocking(move || { + save_playlist_candidate(&store, ¤t_for_save, next, &restore_mutations) + }) + .await + .map_err(|error| error.to_string())?; + if let Err(error) = result { + current + .lock() + .expect("playlist mutex poisoned") + .authoritative = false; + operation.remote_resolved(); + return Err(error); + } operation.remote_resolved(); Ok(operation) }); @@ -182,15 +218,12 @@ impl PlaylistRestore<'_> { } pub(crate) fn replace(&self, next: PlaylistCache) -> Result<(), String> { - self.state - .store - .save(&next) - .map_err(|error| error.to_string())?; - *self.state.current.lock().expect("playlist mutex poisoned") = CurrentPlaylistCache { - cache: next, - authoritative: true, - }; - Ok(()) + save_playlist_candidate( + &self.state.store, + &self.state.current, + next, + &self.state.restore_mutations, + ) } pub(crate) fn install_recovered(&self, next: PlaylistCache) { @@ -297,12 +330,19 @@ mod tests { assert_eq!(store.load().unwrap(), current); let requests = client.transport().requests(); assert_eq!(requests.len(), 2); - assert!(requests - .iter() - .any(|request| request.url.ends_with("/playlists/first/followers"))); - assert!(requests + let playlist_uris = requests .iter() - .any(|request| request.url.ends_with("/playlists/second/followers"))); + .filter_map(|request| url::Url::parse(&request.url).ok()) + .filter(|url| url.path() == "/v1/me/library") + .flat_map(|url| { + url.query_pairs() + .filter(|(key, _)| key == "uris") + .map(|(_, uri)| uri.into_owned()) + .collect::>() + }) + .collect::>(); + assert!(playlist_uris.contains(&"spotify:playlist:first".into())); + assert!(playlist_uris.contains(&"spotify:playlist:second".into())); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/apps/desktop/src-tauri/src/playlists.rs b/apps/desktop/src-tauri/src/playlists.rs index 468f48d..b93dbc1 100644 --- a/apps/desktop/src-tauri/src/playlists.rs +++ b/apps/desktop/src-tauri/src/playlists.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use retune_core::model::Library; use retune_spotify::{ client::{Playlist, SpotifyClient, Track, Transport}, - tokens::{TokenStore, Tokens}, + tokens::Tokens, Error, }; use serde::{Deserialize, Serialize}; @@ -96,8 +96,8 @@ pub struct CachedTrack { pub release_date: Option, } -pub async fn sync( - client: &SpotifyClient, +pub async fn sync( + client: &SpotifyClient, current: &PlaylistCache, ) -> retune_spotify::Result { let user_id = client.me().await?.id; @@ -197,8 +197,8 @@ pub fn reorder_playlists(cache: &mut PlaylistCache, ids: &[String]) -> Result<() Ok(()) } -pub async fn unfollow( - client: &SpotifyClient, +pub async fn unfollow( + client: &SpotifyClient, cache: &mut PlaylistCache, id: &str, ) -> retune_spotify::Result<()> { @@ -211,8 +211,8 @@ pub async fn unfollow( Ok(()) } -pub async fn create( - client: &SpotifyClient, +pub async fn create( + client: &SpotifyClient, cache: &mut PlaylistCache, name: &str, ) -> retune_spotify::Result<()> { @@ -232,8 +232,8 @@ pub async fn create( Ok(()) } -pub async fn add( - client: &SpotifyClient, +pub async fn add( + client: &SpotifyClient, cache: &mut PlaylistCache, library: &Library, id: &str, @@ -320,8 +320,8 @@ impl From for PlaylistAddError { } } -pub async fn reorder( - client: &SpotifyClient, +pub async fn reorder( + client: &SpotifyClient, cache: &mut PlaylistCache, id: &str, range_start: u32, @@ -384,8 +384,8 @@ impl From for PlaylistMutationError { } } -async fn recover_stale_snapshot( - client: &SpotifyClient, +async fn recover_stale_snapshot( + client: &SpotifyClient, cache: &mut PlaylistCache, id: &str, result: retune_spotify::Result, @@ -410,8 +410,8 @@ async fn recover_stale_snapshot( } } -pub async fn remove( - client: &SpotifyClient, +pub async fn remove( + client: &SpotifyClient, cache: &mut PlaylistCache, id: &str, indices: &[u32], @@ -502,8 +502,8 @@ pub(crate) fn classify_error(error: Error, tokens: Option<&Tokens>) -> PlaylistF } } -async fn refresh_one( - client: &SpotifyClient, +async fn refresh_one( + client: &SpotifyClient, cache: &mut PlaylistCache, id: &str, ) -> retune_spotify::Result<()> { @@ -534,8 +534,8 @@ async fn refresh_one( } } -async fn fetch( - client: &SpotifyClient, +async fn fetch( + client: &SpotifyClient, summary: Playlist, ) -> retune_spotify::Result { let (track_count, tracks, spotify_tracks) = fetch_tracks(client, &summary.id).await?; @@ -552,8 +552,8 @@ async fn fetch( }) } -async fn fetch_tracks( - client: &SpotifyClient, +async fn fetch_tracks( + client: &SpotifyClient, id: &str, ) -> retune_spotify::Result<(usize, Vec, Vec)> { let mut tracks = vec![]; @@ -1254,7 +1254,7 @@ mod tests { assert_eq!(request.method, Method::Delete); assert_eq!( url::Url::parse(&request.url).unwrap().path(), - "/v1/playlists/playlist/followers" + "/v1/me/library" ); } diff --git a/apps/desktop/src-tauri/src/provider.rs b/apps/desktop/src-tauri/src/provider.rs index c173ca0..f01c908 100644 --- a/apps/desktop/src-tauri/src/provider.rs +++ b/apps/desktop/src-tauri/src/provider.rs @@ -2,7 +2,6 @@ use retune_core::model::NewTrack; use retune_spotify::{ client::{endpoint_family, Album, Artist, Image, Page, SearchSource, SpotifyClient, Transport}, normalize, - tokens::TokenStore, }; use std::{ collections::{BTreeMap, BTreeSet}, @@ -167,16 +166,16 @@ pub trait MediaProvider: Send + Sync { } } -pub struct SpotifySyncProvider<'a, T, S> { - client: &'a SpotifyClient, +pub struct SpotifySyncProvider<'a, T> { + client: &'a SpotifyClient, run: SyncRun<'a>, account_id: Option, } -impl<'a, T: Transport, S: TokenStore> SpotifySyncProvider<'a, T, S> { +impl<'a, T: Transport> SpotifySyncProvider<'a, T> { #[cfg(test)] pub fn new( - client: &'a SpotifyClient, + client: &'a SpotifyClient, cooldown_store: &'a FsCooldownStore, artist_genres_store: &'a FsArtistGenresStore, ) -> Result { @@ -184,7 +183,7 @@ impl<'a, T: Transport, S: TokenStore> SpotifySyncProvider<'a, T, S> { } pub fn for_account( - client: &'a SpotifyClient, + client: &'a SpotifyClient, cooldown_store: &'a FsCooldownStore, artist_genres_store: &'a FsArtistGenresStore, account_id: impl Into, @@ -198,7 +197,7 @@ impl<'a, T: Transport, S: TokenStore> SpotifySyncProvider<'a, T, S> { } fn new_with_account( - client: &'a SpotifyClient, + client: &'a SpotifyClient, cooldown_store: &'a FsCooldownStore, artist_genres_store: &'a FsArtistGenresStore, account_id: Option, @@ -554,13 +553,13 @@ impl<'a> SyncHealth<'a> { } } -struct GenreSource<'c, T: Transport, S: TokenStore> { - client: &'c SpotifyClient, +struct GenreSource<'c, T: Transport> { + client: &'c SpotifyClient, health: &'c SyncHealth<'c>, } -impl<'c, T: Transport, S: TokenStore> GenreSource<'c, T, S> { - fn new(client: &'c SpotifyClient, health: &'c SyncHealth<'c>) -> Self { +impl<'c, T: Transport> GenreSource<'c, T> { + fn new(client: &'c SpotifyClient, health: &'c SyncHealth<'c>) -> Self { Self { client, health } } @@ -681,8 +680,8 @@ fn normalized_track( } } -async fn enrich_music( - genres: &GenreSource<'_, T, S>, +async fn enrich_music( + genres: &GenreSource<'_, T>, mut batches: Vec>, ) -> Result>, String> { let ids = batches @@ -718,8 +717,8 @@ async fn enrich_music( .collect()) } -async fn normalized_album_tracks( - client: &SpotifyClient, +async fn normalized_album_tracks( + client: &SpotifyClient, health: &SyncHealth<'_>, album: &Album, added_at: Option, @@ -786,8 +785,8 @@ pub(crate) fn saved_album_record( } } -async fn spotify_library_snapshot<'a, T: Transport, S: TokenStore>( - client: &'a SpotifyClient, +async fn spotify_library_snapshot<'a, T: Transport>( + client: &'a SpotifyClient, kind: LibraryKind, run: Option<&'a SyncRun<'a>>, account_id: Option<&str>, @@ -1050,8 +1049,8 @@ async fn spotify_library_snapshot<'a, T: Transport, S: TokenStore>( }) } -async fn spotify_library_snapshots<'a, T: Transport, S: TokenStore>( - client: &'a SpotifyClient, +async fn spotify_library_snapshots<'a, T: Transport>( + client: &'a SpotifyClient, run: Option<&'a SyncRun<'a>>, account_id: Option<&str>, kinds: &[LibraryKind], @@ -1079,7 +1078,7 @@ async fn spotify_library_snapshots<'a, T: Transport, S: TokenStore>( Ok(snapshots) } -impl MediaProvider for SpotifyClient { +impl MediaProvider for SpotifyClient { async fn complete_snapshot( &self, on_section: &mut (dyn FnMut(LibraryKind) + Send), @@ -1098,8 +1097,8 @@ fn search_group(page: Page, offset: u32, map: impl FnMut(T) -> U) -> Se } } -pub async fn search_with_source( - client: &SpotifyClient, +pub async fn search_with_source( + client: &SpotifyClient, query: &str, offset: u32, ) -> Result { @@ -1149,8 +1148,8 @@ pub async fn search_with_source( } #[cfg(test)] -pub async fn search( - client: &SpotifyClient, +pub async fn search( + client: &SpotifyClient, query: &str, offset: u32, ) -> Result { @@ -1159,8 +1158,8 @@ pub async fn search( .map(|response| response.results) } -pub async fn search_albums_with_source( - client: &SpotifyClient, +pub async fn search_albums_with_source( + client: &SpotifyClient, query: &str, ) -> Result<(SearchGroup, SearchSource), String> { let response = @@ -1174,8 +1173,8 @@ pub async fn search_albums_with_source( } #[cfg(test)] -pub async fn search_albums( - client: &SpotifyClient, +pub async fn search_albums( + client: &SpotifyClient, query: &str, ) -> Result, String> { search_albums_with_source(client, query) @@ -1183,8 +1182,8 @@ pub async fn search_albums( .map(|(results, _)| results) } -pub async fn search_tracks_with_source( - client: &SpotifyClient, +pub async fn search_tracks_with_source( + client: &SpotifyClient, query: &str, ) -> Result<(SearchGroup, SearchSource), String> { let response = @@ -1217,8 +1216,8 @@ pub async fn search_tracks_with_source( )) } -pub async fn album_tracks( - client: &SpotifyClient, +pub async fn album_tracks( + client: &SpotifyClient, album: &str, ) -> Result, String> { album_content(client, album, None) @@ -1242,8 +1241,8 @@ impl std::fmt::Display for AlbumContentError { } } -pub async fn album_content( - client: &SpotifyClient, +pub async fn album_content( + client: &SpotifyClient, album: &str, added_at: Option, ) -> Result<(Album, Vec), AlbumContentError> { @@ -1286,8 +1285,8 @@ pub async fn album_content( Ok((album, tracks)) } -pub async fn artist_albums_page( - client: &SpotifyClient, +pub async fn artist_albums_page( + client: &SpotifyClient, artist: &str, offset: u32, ) -> retune_spotify::Result { @@ -1306,7 +1305,7 @@ pub async fn artist_albums_page( }) } -impl MediaProvider for SpotifySyncProvider<'_, T, S> { +impl MediaProvider for SpotifySyncProvider<'_, T> { async fn complete_snapshot( &self, on_section: &mut (dyn FnMut(LibraryKind) + Send), @@ -1398,21 +1397,19 @@ mod tests { use super::*; - fn client( - responses: impl IntoIterator, - ) -> SpotifyClient { + fn client(responses: impl IntoIterator) -> SpotifyClient { fake_client(responses, "") } async fn client_snapshot( - client: &SpotifyClient, + client: &SpotifyClient, kind: LibraryKind, ) -> Result { spotify_library_snapshot(client, kind, None, None, &|_| {}).await } async fn provider_snapshot( - provider: &SpotifySyncProvider<'_, FakeTransport, InMemoryTokenStore>, + provider: &SpotifySyncProvider<'_, FakeTransport>, kind: LibraryKind, ) -> Result { spotify_library_snapshot( diff --git a/apps/desktop/src-tauri/src/restore.rs b/apps/desktop/src-tauri/src/restore.rs index 7d78062..cb0c47b 100644 --- a/apps/desktop/src-tauri/src/restore.rs +++ b/apps/desktop/src-tauri/src/restore.rs @@ -1,7 +1,6 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; +#[cfg(test)] +use std::fs; +use std::path::{Path, PathBuf}; use retune_core::model::Library; use serde::{Deserialize, Serialize}; @@ -10,7 +9,7 @@ use crate::{ lastfm_import::{ load_mappings_for_recovery, save_mappings_for_recovery, PersistedLastFmMappings, }, - persistence::{atomic_write, read_limited}, + persistence::{atomic_write, durable_remove, read_limited}, playlists::PlaylistCache, store::{FsOverlayStore, FsPlaylistStore, FsSettingsStore, OverlayStore, Settings}, }; @@ -121,13 +120,31 @@ impl RestoreStore { } pub(crate) fn cleanup(&self) -> Result<(), RestoreError> { - match fs::remove_file(&self.path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(RestoreError::Persistence(format!( + durable_remove(&self.path).map_err(|error| { + RestoreError::Persistence(format!( "Could not remove completed restore journal: {error}" - ))), + )) + }) + } + + pub(crate) fn recover_exact(&self, expected: &RestoreJournal) -> Result { + let Some(actual) = self.load()? else { + return Ok(false); + }; + if actual == *expected { + self.recover()?; + return Ok(true); + } + let mut complete = expected.clone(); + complete.phase = Phase::Complete; + if actual == complete { + self.recover()?; + return Ok(true); } + if actual.phase == Phase::Complete { + return Ok(false); + } + Err(RestoreError::Conflict("restore journal")) } fn save(&self, journal: &RestoreJournal) -> Result<(), RestoreError> { @@ -159,6 +176,7 @@ impl RestoreStore { return Ok(()); }; if journal.phase == Phase::Complete { + self.save(&journal)?; if let Err(error) = self.cleanup() { log::warn!("{error}"); } @@ -444,6 +462,25 @@ mod tests { assert!(!dir.path().join(RESTORE_JOURNAL_FILE).exists()); } + #[test] + fn matching_complete_journal_reflushes_after_parent_sync_failure() { + let dir = tempdir().unwrap(); + let journal = RestoreJournal::applying(libraries(), None, None, None); + let store = RestoreStore::new(dir.path()); + let path = dir.path().join(RESTORE_JOURNAL_FILE); + store.complete(&journal).unwrap(); + + crate::persistence::fail_next_atomic_write( + &path, + crate::persistence::FailureStage::ParentSync, + ); + assert!(store.recover_exact(&journal).is_err()); + assert!(path.exists()); + + assert!(store.recover_exact(&journal).unwrap()); + assert!(!path.exists()); + } + #[test] fn components_absent_from_backup_are_untouched() { let dir = tempdir().unwrap(); diff --git a/apps/desktop/src-tauri/src/spotify_commands.rs b/apps/desktop/src-tauri/src/spotify_commands.rs index a01edc4..e9dfb6a 100644 --- a/apps/desktop/src-tauri/src/spotify_commands.rs +++ b/apps/desktop/src-tauri/src/spotify_commands.rs @@ -266,13 +266,21 @@ async fn run_sync_loop( async fn record_failed_sync_schedule(app: &tauri::AppHandle) -> Option { let state = app.state::(); - let connected = match stored_connection_state(&state.token_store) { - Ok(connection) => connection.connected, - Err(error) => { - log::warn!("Could not inspect Spotify connection after sync failure: {error}"); - false - } - }; + let token_store = Arc::clone(&state.token_store); + let connected = + match tauri::async_runtime::spawn_blocking(move || stored_connection_state(&token_store)) + .await + { + Ok(Ok(connection)) => connection.connected, + Ok(Err(error)) => { + log::warn!("Could not inspect Spotify connection after sync failure: {error}"); + false + } + Err(error) => { + log::warn!("Could not inspect Spotify connection after sync failure: {error}"); + false + } + }; if !connected { return None; } @@ -357,8 +365,7 @@ pub(super) fn spotify_sync_status_snapshot( } pub(super) fn emit_spotify_sync_status(app: &tauri::AppHandle) -> Result<(), String> { - let status = spotify_sync_status_snapshot(app)?; - emit_main(app, "spotify-sync-status-changed", status).map_err(|error| error.to_string()) + emit_main(app, "spotify-sync-status-changed", ()).map_err(|error| error.to_string()) } #[tauri::command] @@ -386,7 +393,9 @@ pub(super) async fn spotify_sync_status( }) .await?; } - spotify_sync_status_snapshot(&app) + tauri::async_runtime::spawn_blocking(move || spotify_sync_status_snapshot(&app)) + .await + .map_err(|error| error.to_string())? } struct SyncCompletion { @@ -542,7 +551,6 @@ async fn sync_spotify_inner(app: &tauri::AppHandle) -> Result { membership: &'a crate::spotify_membership::SpotifyMembership, library: &'a crate::library_state::LibraryState, settings: &'a crate::store::SettingsState, - token_store: &'a crate::SharedTokenStore, cooldown_store: &'a FsCooldownStore, artist_genres_store: &'a crate::store::FsArtistGenresStore, lastfm_import: &'a crate::lastfm_import::Service, @@ -677,7 +684,12 @@ impl SpotifySyncApplication<'_> { ) -> Result { let session_revision = self.session.revision(); let baseline_membership = self.membership.snapshot(); - if !stored_connection_state(self.token_store)?.connected { + if !provider + .load_tokens() + .await + .map_err(|error| error.to_string())? + .is_some() + { return Err("Connect to Spotify before syncing.".into()); } let profile = provider @@ -696,10 +708,15 @@ impl SpotifySyncApplication<'_> { let first_sync = !self.settings.snapshot().spotify_sync_completed; let outcome = sync::snapshot(&sync_provider, progress, on_batch).await?; let session_commit = self.session.commit_revision(session_revision).await?; - if !stored_connection_state(self.token_store)?.connected { + if !provider + .load_tokens() + .await + .map_err(|error| error.to_string())? + .is_some() + { return Err("The Spotify connection changed during sync. Try again.".into()); } - remember_playback_profile_id(self.token_store, &profile.id)?; + remember_playback_profile_id(provider, &profile.id).await?; let membership = self.membership.lock().await; self.lastfm_import .migrate_spotify_account_id(&profile.id, &account_id) @@ -818,22 +835,25 @@ async fn commit_sync_state( }, ); let commit_dir = app_data_dir.clone(); - if let Err(error) = tauri::async_runtime::spawn_blocking(move || { - #[cfg(test)] - if let Some((boundary, hook)) = commit_hook { - return crate::spotify_sync_commit::Store::pausing_before( - &commit_dir, - boundary, - hook, - ) - .commit(&journal); - } - crate::spotify_sync_commit::Store::new(&commit_dir).commit(&journal) - }) - .await - .map_err(|error| error.to_string())? - { - if app_data_dir.join("spotify-sync-journal.json").exists() { + let (commit_result, journal_needs_recovery) = + tauri::async_runtime::spawn_blocking(move || { + #[cfg(test)] + let store = if let Some((boundary, hook)) = commit_hook { + crate::spotify_sync_commit::Store::pausing_before(&commit_dir, boundary, hook) + } else { + crate::spotify_sync_commit::Store::new(&commit_dir) + }; + #[cfg(not(test))] + let store = crate::spotify_sync_commit::Store::new(&commit_dir); + let result = store.commit(&journal); + let needs_recovery = + result.is_err() && store.needs_recovery_after_error(&journal).unwrap_or(true); + (result, needs_recovery) + }) + .await + .map_err(|error| error.to_string())?; + if let Err(error) = commit_result { + if journal_needs_recovery { restore_mutations.mark_recovery_required(); } return Err(error); @@ -980,8 +1000,8 @@ pub(super) fn mark_track_membership( } } -pub(super) async fn artist_albums_outcome( - provider: &SpotifyClient, +pub(super) async fn artist_albums_outcome( + provider: &SpotifyClient, cooldown_store: &FsCooldownStore, artist_id: &str, offset: u32, @@ -1226,12 +1246,13 @@ fn web_oauth_tokens(access: String, refresh: String, expires_at: u64, scopes: St } } -fn remember_playback_profile_id( - token_store: &impl TokenStore, +async fn remember_playback_profile_id( + client: &SpotifyClient, profile_id: &str, ) -> Result<(), String> { - let mut expected = token_store - .load() + let mut expected = client + .load_tokens() + .await .map_err(|error| error.to_string())? .ok_or_else(|| "Connect to Spotify before syncing.".to_string())?; loop { @@ -1247,14 +1268,16 @@ fn remember_playback_profile_id( username: profile_id.to_owned(), auth_data: vec![], }); - if token_store - .replace_if_current(&expected, &replacement) + if client + .replace_tokens_if_current(&expected, &replacement) + .await .map_err(|error| error.to_string())? { return Ok(()); } - expected = token_store - .load() + expected = client + .load_tokens() + .await .map_err(|error| error.to_string())? .ok_or_else(|| "The Spotify connection changed during sync. Try again.".to_string())?; } @@ -1362,9 +1385,9 @@ pub(super) async fn connect_spotify(app: tauri::AppHandle) -> Result<(), String> pub(super) async fn authorize_spotify_playback(app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let provider = provider_from(&state)?; - let expected_tokens = state - .token_store - .load() + let expected_tokens = provider + .load_tokens() + .await .map_err(|error| error.to_string())? .ok_or_else(|| "Connect to Spotify before authorizing playback.".to_string())?; let attempt = state @@ -1445,16 +1468,16 @@ pub(super) async fn authorize_spotify_playback(app: tauri::AppHandle) -> Result< playback_credentials(&web_account_id, playback_username, playback_auth_data)?; let _commit = attempt.commit(&expected_tokens.access).await?; let _membership_guard = state.spotify_membership.lock().await; - let mut tokens = state - .token_store - .load() + let mut tokens = provider + .load_tokens() + .await .map_err(|error| error.to_string())? .ok_or_else(|| "Connect to Spotify before authorizing playback.".to_string())?; let expected = tokens.clone(); tokens.playback_credentials = Some(playback_credentials); - if !state - .token_store - .replace_if_current(&expected, &tokens) + if !provider + .replace_tokens_if_current(&expected, &tokens) + .await .map_err(|error| error.to_string())? { return Err( @@ -1530,7 +1553,7 @@ pub(super) async fn track_artwork( ); resolve_track_artwork( provider.as_deref(), - &state.artwork_cache, + &state.local_artwork_cache, local_path, &uri, min_width.unwrap_or(64).clamp(1, 2048), @@ -1571,10 +1594,15 @@ pub(super) async fn spotify_search( }, }); } - if !stored_connection_state(&state.token_store)?.connected { + let provider = provider_from(&state)?; + if !provider + .load_tokens() + .await + .map_err(|error| error.to_string())? + .is_some() + { return Err("Connect to Spotify to search.".into()); } - let provider = provider_from(&state)?; let response = provider::search_with_source(provider.as_ref(), query.trim(), offset).await?; if response.source == SearchSource::Network { state @@ -1912,8 +1940,8 @@ mod tests { use retune_core::model::{AlbumKey, EffectiveRating, Library, NewTrack, Rating, SourceId}; use retune_spotify::{ - client::{Album, Image, Page, SimplifiedArtist, Track}, - tokens::{InMemoryTokenStore, TokenStore}, + client::{Album, FakeTransport, Image, Page, SimplifiedArtist, SpotifyClient, Track}, + tokens::InMemoryTokenStore, }; use super::{ @@ -2162,18 +2190,27 @@ mod tests { assert!(error.contains("different Spotify account")); } - #[test] - fn sync_retains_profile_id_for_playback_authorization() { + #[tokio::test] + async fn sync_retains_profile_id_for_playback_authorization() { let store = InMemoryTokenStore::new(Some(web_oauth_tokens( "access".into(), "refresh".into(), 10, "scope".into(), ))); + let client = SpotifyClient::new("client", FakeTransport::default(), store); - remember_playback_profile_id(&store, "web-user").unwrap(); + remember_playback_profile_id(&client, "web-user") + .await + .unwrap(); - let cached = store.load().unwrap().unwrap().playback_credentials.unwrap(); + let cached = client + .token_store() + .load() + .unwrap() + .unwrap() + .playback_credentials + .unwrap(); assert_eq!(cached.username, "web-user"); assert!(cached.auth_data.is_empty()); } diff --git a/apps/desktop/src-tauri/src/spotify_membership.rs b/apps/desktop/src-tauri/src/spotify_membership.rs index 46bfcc8..6713342 100644 --- a/apps/desktop/src-tauri/src/spotify_membership.rs +++ b/apps/desktop/src-tauri/src/spotify_membership.rs @@ -4,10 +4,7 @@ use std::{ }; use retune_core::model::{Library, NewTrack, TrackRecord}; -use retune_spotify::{ - client::{Album, SpotifyClient, Transport}, - tokens::TokenStore, -}; +use retune_spotify::client::{Album, SpotifyClient, Transport}; use crate::{ library_state::LibraryOwner, @@ -16,6 +13,50 @@ use crate::{ store::{self, FsCooldownStore, FsSpotifyLibraryStore, SpotifyLibraryState}, }; +fn save_membership_candidate( + store: &FsSpotifyLibraryStore, + current: &Mutex, + next: SpotifyLibraryState, + restore_mutations: &crate::restore_latch::RestoreMutationState, +) -> store::StoreResult<()> { + let existed_before = match store.exists() { + Ok(existed) => existed, + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(error.into()); + } + }; + match store.save(&next) { + Ok(()) => { + *current.lock().expect("Spotify library mutex poisoned") = next; + Ok(()) + } + Err(error) => { + let disk = match store.exists() { + Ok(false) if !existed_before => Ok(None), + Ok(true) => store.load().map(Some), + Ok(false) => { + Err(std::io::Error::other("Spotify library file disappeared after save").into()) + } + Err(error) => Err(error.into()), + }; + let before = current + .lock() + .expect("Spotify library mutex poisoned") + .clone(); + match disk { + Ok(Some(actual)) if actual == next => { + *current.lock().expect("Spotify library mutex poisoned") = actual; + } + Ok(Some(actual)) if actual == before => {} + Ok(None) if !existed_before => {} + _ => restore_mutations.mark_recovery_required(), + } + Err(error) + } + } +} + pub(super) fn album_track_uris(album: &Album) -> Vec { album .tracks @@ -226,13 +267,14 @@ impl SpotifyMembershipGuard { .map_err(std::io::Error::other)?; let gate = self.gate.take().expect("membership guard is active"); let store = self.store.clone(); - let saved = next.clone(); - let current = Arc::clone(&self.current); + let current_for_save = Arc::clone(&self.current); + let restore_mutations = Arc::clone(&self.restore_mutations); let completion = tauri::async_runtime::spawn(async move { - tauri::async_runtime::spawn_blocking(move || store.save(&saved)) - .await - .map_err(|error| std::io::Error::other(error.to_string()))??; - *current.lock().expect("Spotify library mutex poisoned") = next; + tauri::async_runtime::spawn_blocking(move || { + save_membership_candidate(&store, ¤t_for_save, next, &restore_mutations) + }) + .await + .map_err(|error| std::io::Error::other(error.to_string()))??; Ok::<_, store::StoreError>((gate, owner)) }); let (gate, owner) = completion @@ -422,8 +464,8 @@ pub(crate) fn spotify_action_error( spotify_action_failure(cooldown_store, error, crate::unix_now()) } -pub(crate) async fn remove_from_library( - provider: &SpotifyClient, +pub(crate) async fn remove_from_library( + provider: &SpotifyClient, cooldown_store: &FsCooldownStore, uris: &[String], now: u64, @@ -440,8 +482,8 @@ pub(crate) struct AlbumSaveResult { // This boundary coordinates independent API, membership, library, cooldown, and metadata inputs. #[allow(clippy::too_many_arguments)] -pub(crate) async fn save_album( - provider: &SpotifyClient, +pub(crate) async fn save_album( + provider: &SpotifyClient, membership: &SpotifyMembership, library_owner: &LibraryOwner, cooldown_store: &FsCooldownStore, @@ -466,8 +508,8 @@ pub(crate) async fn save_album( // The locked variant preserves those explicit dependencies for its single caller. #[allow(clippy::too_many_arguments)] -pub(crate) async fn save_album_locked( - provider: &SpotifyClient, +pub(crate) async fn save_album_locked( + provider: &SpotifyClient, membership: &mut SpotifyMembershipGuard, library_owner: &LibraryOwner, cooldown_store: &FsCooldownStore, @@ -528,8 +570,8 @@ pub(crate) async fn save_album_locked( }) } -pub(crate) async fn save_tracks( - provider: &SpotifyClient, +pub(crate) async fn save_tracks( + provider: &SpotifyClient, membership: &SpotifyMembership, library_owner: &LibraryOwner, cooldown_store: &FsCooldownStore, @@ -550,8 +592,8 @@ pub(crate) async fn save_tracks( .await } -pub(crate) async fn save_tracks_locked( - provider: &SpotifyClient, +pub(crate) async fn save_tracks_locked( + provider: &SpotifyClient, membership: &mut SpotifyMembershipGuard, library_owner: &LibraryOwner, cooldown_store: &FsCooldownStore, @@ -930,7 +972,8 @@ mod tests { let gate = state.spotify_membership.lock().await; let library_path = directory.path().join("library.json"); - std::fs::create_dir(&library_path).unwrap(); + let hook = crate::store::SaveHook::new(true); + state.library.arm_save(Arc::clone(&hook)); let blocked = tokio::spawn({ let state = Arc::clone(&state); let client = Arc::clone(&client); @@ -959,6 +1002,11 @@ mod tests { .is_err() ); drop(gate); + while !hook.is_reached() { + tokio::task::yield_now().await; + } + hook.wait_until_reached(); + hook.release(); assert!(blocked.await.unwrap().is_err()); assert!(state @@ -972,10 +1020,9 @@ mod tests { .saved_tracks .contains_key("spotify:track:one")); assert!(state.library.lock().unwrap().tracks().is_empty()); - assert!(!library_path.is_file()); + assert!(!library_path.exists()); assert_eq!(client.transport().requests().len(), 1); - std::fs::remove_dir(&library_path).unwrap(); let library_owner = state.library.owner(); let ids = save_tracks( client.as_ref(), diff --git a/apps/desktop/src-tauri/src/spotify_sync_commit.rs b/apps/desktop/src-tauri/src/spotify_sync_commit.rs index 91cce01..fa649f7 100644 --- a/apps/desktop/src-tauri/src/spotify_sync_commit.rs +++ b/apps/desktop/src-tauri/src/spotify_sync_commit.rs @@ -1,13 +1,10 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use retune_core::model::Library; use serde::{Deserialize, Serialize}; use crate::{ - persistence::{atomic_write, read_limited}, + persistence::{atomic_write, durable_remove, read_limited}, store::{ FsOverlayStore, FsSettingsStore, FsSpotifyLibraryStore, OverlayStore, Settings, SpotifyLibraryState, @@ -98,7 +95,17 @@ impl Store { } pub(crate) fn commit(&self, journal: &Journal) -> Result<(), String> { - self.save(journal)?; + if let Err(begin_error) = self.save(journal) { + match self.recover_exact_applying(journal) { + Ok(true) => return Ok(()), + Ok(false) => return Err(begin_error), + Err(recovery_error) => { + return Err(format!( + "Spotify sync journal could not be started ({begin_error}) and immediate recovery failed ({recovery_error}). Restart Retune to recover before making more changes." + )); + } + } + } let primary = self .roll_forward(journal) .and_then(|()| self.mark_complete(journal)); @@ -124,6 +131,7 @@ impl Store { return Ok(()); }; if journal.phase == Phase::Complete { + self.save(&journal)?; if let Err(error) = self.cleanup() { log::warn!("Could not remove completed Spotify sync journal: {error}"); } @@ -138,6 +146,38 @@ impl Store { Ok(()) } + pub(crate) fn needs_recovery_after_error(&self, expected: &Journal) -> Result { + let Some(actual) = self.load()? else { + return Ok(false); + }; + if actual == *expected { + return Ok(true); + } + let mut expected_complete = expected.clone(); + expected_complete.phase = Phase::Complete; + if actual == expected_complete { + return Ok(true); + } + if actual.phase == Phase::Complete { + return Ok(false); + } + Err("Spotify sync journal contains a different Applying transaction.".into()) + } + + fn recover_exact_applying(&self, expected: &Journal) -> Result { + let Some(actual) = self.load()? else { + return Ok(false); + }; + if actual == *expected { + self.recover()?; + return Ok(true); + } + if actual.phase == Phase::Complete { + return Ok(false); + } + Err("Spotify sync journal conflicts with the intended transaction.".into()) + } + fn roll_forward(&self, journal: &Journal) -> Result<(), String> { self.write_component(0, || { FsSpotifyLibraryStore::new(&self.app_data_dir) @@ -239,11 +279,7 @@ impl Store { } fn cleanup(&self) -> Result<(), String> { - match fs::remove_file(&self.path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.to_string()), - } + durable_remove(&self.path).map_err(|error| error.to_string()) } } @@ -363,4 +399,44 @@ mod tests { assert_after(dir.path(), &journal); } } + + #[test] + fn complete_reflush_failure_keeps_recovery_required_until_retry() { + let dir = tempdir().unwrap(); + let journal = fixture(); + let store = Store::new(dir.path()); + store.save(&journal).unwrap(); + store.roll_forward(&journal).unwrap(); + store.mark_complete(&journal).unwrap(); + + crate::persistence::fail_next_atomic_write( + &dir.path().join(FILE), + crate::persistence::FailureStage::ParentSync, + ); + assert!(store.recover().is_err()); + assert!(store.needs_recovery_after_error(&journal).unwrap()); + + store.recover().unwrap(); + assert!(!store.needs_recovery_after_error(&journal).unwrap()); + assert!(!dir.path().join(FILE).exists()); + } + + #[test] + fn failed_begin_does_not_accept_unrelated_complete_journal() { + let dir = tempdir().unwrap(); + let expected = fixture(); + let mut unrelated = fixture(); + unrelated.membership.after.account_id = "other-account".into(); + unrelated.phase = Phase::Complete; + let store = Store::new(dir.path()); + store.save(&unrelated).unwrap(); + + crate::persistence::fail_next_atomic_write( + &dir.path().join(FILE), + crate::persistence::FailureStage::Rename, + ); + let error = store.commit(&expected).unwrap_err(); + assert!(error.contains("injected atomic-write failure")); + assert_eq!(store.load().unwrap(), Some(unrelated)); + } } diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index e4e1b0a..89b1c76 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -20,7 +20,7 @@ use retune_spotify::tokens::{TokenStore, Tokens}; use serde::{Deserialize, Serialize}; use crate::{ - persistence::{atomic_write, read_limited, read_limited_file}, + persistence::{atomic_write, durable_remove, read_limited, read_limited_file}, playback::{PlaybackBackend, RepeatMode}, playlists::PlaylistCache, }; @@ -98,6 +98,10 @@ pub type StoreResult = Result; pub trait OverlayStore { fn load(&self) -> StoreResult>; fn save(&self, library: &Library) -> StoreResult<()>; + + fn exists(&self) -> StoreResult { + Ok(true) + } } #[derive(Clone)] @@ -922,6 +926,10 @@ impl FsPlaylistStore { } } + pub(crate) fn exists(&self) -> std::io::Result { + self.path.try_exists() + } + #[cfg(test)] pub(crate) fn arm_save(&self, hook: Arc) { *self.save_hook.lock().unwrap() = Some(hook); @@ -1100,7 +1108,11 @@ impl FsCooldownStore { let result = update(&mut next); coalesce_legacy_quota_entries(&mut next); if &next != current { - atomic_write(&self.path, &serde_json::to_vec(&next)?, None)?; + let bytes = serde_json::to_vec(&next)?; + if let Err(error) = atomic_write(&self.path, &bytes, None) { + state.cooldowns = None; + return Err(error.into()); + } state.cooldowns = Some(next); } Ok(result) @@ -1111,7 +1123,11 @@ impl FsCooldownStore { self.load_cooldowns()?; let mut state = self.state.lock().expect("cooldown state mutex poisoned"); if state.cooldowns.as_ref() != Some(cooldowns) { - atomic_write(&self.path, &serde_json::to_vec(cooldowns)?, None)?; + let bytes = serde_json::to_vec(cooldowns)?; + if let Err(error) = atomic_write(&self.path, &bytes, None) { + state.cooldowns = None; + return Err(error.into()); + } state.cooldowns = Some(cooldowns.clone()); } Ok(()) @@ -1337,6 +1353,10 @@ impl FsSpotifyLibraryStore { } } + pub(crate) fn exists(&self) -> std::io::Result { + self.path.try_exists() + } + #[cfg(test)] pub(crate) fn arm_save(&self, hook: Arc) { *self.save_hook.lock().unwrap() = Some(hook); @@ -1389,6 +1409,10 @@ impl FsSettingsStore { } } + pub(crate) fn exists(&self) -> std::io::Result { + self.path.try_exists() + } + #[cfg(test)] pub(crate) fn arm_save(&self, hook: Arc) { *self.save_hook.lock().unwrap() = Some(hook); @@ -1428,6 +1452,38 @@ impl FsSettingsStore { } } +fn save_settings_candidate( + store: &FsSettingsStore, + before: &Settings, + next: Settings, + restore_mutations: &crate::restore_latch::RestoreMutationState, + publish: impl FnOnce(Settings), +) -> Result<(), String> { + let existed_before = match store.exists() { + Ok(existed) => existed, + Err(error) => { + restore_mutations.mark_recovery_required(); + return Err(error.to_string()); + } + }; + match store.save(&next) { + Ok(()) => { + publish(next); + Ok(()) + } + Err(error) => { + let message = error.to_string(); + match store.load() { + Ok(Some(actual)) if actual == next => publish(next), + Ok(Some(actual)) if actual == *before => {} + Ok(None) if !existed_before => {} + _ => restore_mutations.mark_recovery_required(), + } + Err(message) + } + } +} + impl SettingsState { #[cfg(test)] pub fn new(current: Settings, store: FsSettingsStore) -> Self { @@ -1501,14 +1557,22 @@ impl SettingsState { return Ok((value, previous)); } let store = self.store.clone(); + let previous_for_save = previous.clone(); let saved = next.clone(); let current = Arc::clone(&self.current); + let restore_mutations = Arc::clone(&self.restore_mutations); let completion = tauri::async_runtime::spawn(async move { - tauri::async_runtime::spawn_blocking(move || store.save(&saved)) - .await - .map_err(|error| error.to_string())? - .map_err(|error| error.to_string())?; - *current.lock().expect("settings mutex poisoned") = next.clone(); + tauri::async_runtime::spawn_blocking(move || { + save_settings_candidate( + &store, + &previous_for_save, + saved, + &restore_mutations, + |next| *current.lock().expect("settings mutex poisoned") = next, + ) + }) + .await + .map_err(|error| error.to_string())??; Ok::<_, String>((mutation_guard, next)) }); let (_mutation_guard, next) = completion @@ -1541,12 +1605,14 @@ impl SettingsRestore<'_> { } pub(crate) fn replace(&mut self, next: Settings) -> Result<(), String> { - self.state - .store - .save(&next) - .map_err(|error| error.to_string())?; - *self.state.current.lock().expect("settings mutex poisoned") = next; - Ok(()) + let before = self.state.snapshot(); + save_settings_candidate( + &self.state.store, + &before, + next, + &self.state.restore_mutations, + |next| *self.state.current.lock().expect("settings mutex poisoned") = next, + ) } pub(crate) fn install_recovered(&mut self, next: Settings) { @@ -1563,6 +1629,10 @@ impl FsOverlayStore { } } + pub(crate) fn exists(&self) -> std::io::Result { + self.path.try_exists() + } + #[cfg(test)] pub(crate) fn arm_save(&self, hook: Arc) { *self.save_hook.lock().unwrap() = Some(hook); @@ -1579,6 +1649,10 @@ impl FsOverlayStore { } impl OverlayStore for FsOverlayStore { + fn exists(&self) -> StoreResult { + FsOverlayStore::exists(self).map_err(Into::into) + } + fn load(&self) -> StoreResult> { match read_limited(&self.path, MAX_LIBRARY_BYTES) { Ok(bytes) => Ok(Some(import(&bytes)?)), @@ -1652,11 +1726,7 @@ impl FsTokenStore { } fn clear_file(&self) -> retune_spotify::Result<()> { - match fs::remove_file(&self.path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(token_error(error)), - } + durable_remove(&self.path).map_err(token_error) } } diff --git a/apps/desktop/src-tauri/tauri.macos.conf.json b/apps/desktop/src-tauri/tauri.macos.conf.json index 6ffc178..b5aa590 100644 --- a/apps/desktop/src-tauri/tauri.macos.conf.json +++ b/apps/desktop/src-tauri/tauri.macos.conf.json @@ -2,7 +2,7 @@ "bundle": { "targets": ["app"], "macOS": { - "minimumSystemVersion": "11.0" + "minimumSystemVersion": "15.0" } } } diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 0751c39..d944598 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -171,6 +171,9 @@ button { color: inherit; } .column-menu { position: absolute; z-index: 10; min-width: 130px; padding: 4px; border: 1px solid var(--border); border-radius: 3px; background: var(--control); box-shadow: 0 3px 10px #0004; } .column-menu label { display: block; padding: 2px 5px; } .column-menu input { margin-right: 6px; } +.column-menu .checkbox-menu-item { position: relative; padding-left: 24px; } +.column-menu .checkbox-menu-item::before { position: absolute; left: 8px; content: '☐'; } +.column-menu .checkbox-menu-item[aria-checked='true']::before { content: '☑'; } .context-menu button { display: block; width: 100%; padding: 3px 8px; border: 0; border-radius: 2px; background: transparent; text-align: left; } .context-menu button:hover { color: #fff; background: var(--accent); } .context-menu button:disabled { color: var(--dim); opacity: .55; } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c9d97e9..acd47c6 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -2,7 +2,7 @@ import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { Fragment, useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react' import './App.css' -import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseTypeaheadContextKey, browseViewForRequest, cancelTrackInfoLoad, COLUMN_SPECS, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, DRAG_LOCAL_TYPE, DRAG_TYPE, entityRequestGeneration, facetLabel, failedPlaylistRows, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentRequestGeneration, isCurrentTrack, labels, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, moveBefore, moveToIndex, normalizeZoom, pendingEntities, pendingPlaybackTarget, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_COLUMNS, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, resolvedPlaylistRows, resizedColumnWidth, routeGlobalShortcut, selectionAfterFacet, simulatedPlaybackTick, staleSelectionFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns, visibleColumnOrder } from './ui.ts' +import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseTypeaheadContextKey, browseViewForRequest, cancelTrackInfoLoad, COLUMN_SPECS, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, DRAG_LOCAL_TYPE, DRAG_TYPE, entityRequestGeneration, facetLabel, failedPlaylistRows, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentRequestGeneration, isCurrentTrack, labels, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, moveBefore, moveToIndex, normalizeZoom, pendingEntities, pendingPlaybackTarget, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_COLUMNS, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, resolvedPlaylistRows, resizedColumnWidth, routeGlobalShortcut, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns, visibleColumnOrder } from './ui.ts' import { defaultSettings, initialState, reducer, type Action, type State } from './appState.ts' import { GetInfo, MultipleItemInformation, PlaybackAuthorization, Preferences, SetupLibrary } from './dialogViews.tsx' import { AlbumRatingStrip, BrowserPane, TrackCell, TrackContextMenu, TrackList } from './libraryViews.tsx' @@ -15,7 +15,7 @@ import { playbackEvents, playbackGateway } from './playbackGateway.ts' import { createPlaybackProgress, documentVisible, onlyPlaybackProgressChanged, subscribeDocumentVisibility } from './playbackProgress.ts' import { useTrackWindow } from './useTrackWindow.ts' import { spotifyEvents, spotifyGateway } from './spotifyGateway.ts' -import { dispatchMainEvent, subscribeInvalidationThenSnapshot, subscribeMainEvents, type MainEventHandlers, type SpotifyPlayRequest } from './ipc.ts' +import { dispatchMainEvent, subscribeInvalidationThenSnapshot, subscribeMainEvents, type MainEvent, type SpotifyPlayRequest } from './ipc.ts' import { appGateway } from './appGateway.ts' import { lastfmGateway } from './lastfmGateway.ts' import { RemoveTrackDialog, TrackMergeDialog } from './trackDecisionDialogs.tsx' @@ -26,31 +26,42 @@ const emptyTracks: Track[] = [] const ZOOM_MIN = 0.7 const ZOOM_MAX = 1.8 const ZOOM_BASE = 1.15 -function useTauriEvent(event: string, handler: (payload: T) => void) { - const ref = useRef(handler) - ref.current = handler +function useTauriEvent(event: string, handler: (payload: T) => void, onError: (error: unknown) => void = () => {}) { + const handle = useEffectEvent(handler) + const reportError = useEffectEvent(onError) useEffect(() => { - const sub = listen(event, ({ payload }) => ref.current(payload)) - return () => { void sub.then((stop) => stop()) } + let active = true + const sub = listen(event, ({ payload }) => { if (active) handle(payload) }) + void sub.catch((error) => { if (active) reportError(error) }) + return () => { + active = false + void sub.then((stop) => stop()).catch(() => {}) + } }, [event]) } -function useTauriInvalidationSnapshot(event: string, snapshot: () => Promise, handler: (payload: T) => void) { - const handlerRef = useRef(handler) - const snapshotRef = useRef(snapshot) - handlerRef.current = handler - snapshotRef.current = snapshot +function useTauriInvalidationSnapshot( + event: string, + snapshot: () => Promise, + handler: (payload: T) => void, + onError: (error: unknown) => void, +) { + const read = useEffectEvent(snapshot) + const handle = useEffectEvent(handler) + const reportError = useEffectEvent(onError) useEffect(() => { let active = true const subscription = subscribeInvalidationThenSnapshot( (invalidate) => listen(event, invalidate), - () => snapshotRef.current(), - (value) => handlerRef.current(value), + () => read(), + (value) => handle(value), () => active, + (error) => { if (active) reportError(error) }, ) + void subscription.catch((error) => { if (active) reportError(error) }) return () => { active = false - void subscription.then((stop) => stop()) + void subscription.then((stop) => stop()).catch(() => {}) } }, [event]) } @@ -87,40 +98,40 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla request().catch((error) => dispatch({ type: 'error', error: String(error) })) }, [dispatch]) - const liveBackend = useCallback(() => { - const current = playingRef.current - return !current?.simulated && (connected || current?.uri?.startsWith('file:')) - }, [connected]) - const start = useCallback((id: number, tracks: readonly PlaybackTrack[], launchOrigin?: PlaybackOrigin) => { + const target = tracks.find((track) => track.id === id) + if (!target || (!target.uri.startsWith('file:') && !target.uri.startsWith('spotify:'))) { + dispatch({ type: 'error', error: `Unsupported playback URI: ${target?.uri ?? 'unknown'}` }) + return + } const playable = playbackQueue(tracks, id) - const target = playable.find((track) => track.id === id) + const targetIndex = playable.findIndex((track) => track.id === id) + if (targetIndex < 0) { + dispatch({ type: 'error', error: `Unsupported playback URI: ${target.uri}` }) + return + } const request = beginRequestGeneration(playGeneration) queue.current = playable origin.current = launchOrigin pendingPlay.current = null dispatch({ type: 'playbackAuthorization', prompt: null }) - if (playbackStartAction(target?.uri, connected) === 'connect') { + if (playbackStartAction(target.uri, connected) === 'connect') { // Kick off the OAuth flow instead of erroring; the pending play fires // once connection-changed reports connected. pendingPlay.current = { id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: false } run(spotifyGateway.connect) return } - if (target?.uri.startsWith('file:') || target?.uri.startsWith('spotify:')) { - playbackGateway.play(playable, playable.findIndex((track) => track.id === id)) - .then((outcome) => { - const authorization = currentPlaybackAuthorization(request, playGeneration, outcome, playable) - if (!authorization) return - pendingPlay.current = { id: authorization.id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: true } - dispatch({ type: 'playbackAuthorization', prompt: authorization.prompt }) - }) - .catch((error) => { - if (isCurrentRequestGeneration(request, playGeneration)) dispatch({ type: 'error', error: String(error) }) - }) - return - } - dispatch({ type: 'play', id, queue: playable, origin: launchOrigin }) + playbackGateway.play(playable, targetIndex) + .then((outcome) => { + const authorization = currentPlaybackAuthorization(request, playGeneration, outcome, playable) + if (!authorization) return + pendingPlay.current = { id: authorization.id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: true } + dispatch({ type: 'playbackAuthorization', prompt: authorization.prompt }) + }) + .catch((error) => { + if (isCurrentRequestGeneration(request, playGeneration)) dispatch({ type: 'error', error: String(error) }) + }) }, [connected, dispatch, run]) useEffect(() => { @@ -138,11 +149,9 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla }, [dispatch]) const toggle = useCallback(() => { - if (liveBackend()) { - if (playingRef.current && !playingRef.current.external) run(playbackGateway.toggle) - } - else dispatch({ type: 'togglePlay' }) - }, [dispatch, liveBackend, run]) + if (!playingRef.current || playingRef.current.external) return + run(playbackGateway.toggle) + }, [run]) const playRequestedTrack = useCallback((track: SpotifyPlayRequest) => { const current = playingRef.current @@ -154,29 +163,19 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla const step = useCallback((direction: number) => { const current = playingRef.current - if (liveBackend()) { - if (current && !current.external) run(direction < 0 ? playbackGateway.previous : playbackGateway.next) - return - } - if (!current?.queue.length || current.trackId === null) return - const index = current.queue.findIndex((track) => track.id === current.trackId) - const next = current.queue[(index + direction + current.queue.length) % current.queue.length] - dispatch({ type: 'step', id: next.id }) - }, [dispatch, liveBackend, run]) + if (!current || current.external) return + run(direction < 0 ? playbackGateway.previous : playbackGateway.next) + }, [run]) const setVolume = useCallback((volume: number) => { - if (!liveBackend()) return window.clearTimeout(volumeTimer.current) volumeTimer.current = window.setTimeout(() => run(() => playbackGateway.setVolume(volume)), 150) - }, [liveBackend, run]) + }, [run]) const seek = useCallback((seconds: number) => { - if (liveBackend()) { - if (playingRef.current && !playingRef.current.external) run(() => playbackGateway.seek(seconds)) - return - } - dispatch({ type: 'seek', elapsed: seconds }) - }, [dispatch, liveBackend, run]) + if (!playingRef.current || playingRef.current.external) return + run(() => playbackGateway.seek(seconds)) + }, [run]) useEffect(() => () => window.clearTimeout(volumeTimer.current), []) @@ -220,7 +219,7 @@ function App() { const playbackTracks = state.playing?.queue ?? emptyTracks const player = usePlayer(state.connection.connected, state.connection.playback_authorized, state.playing, dispatch) - const mainEventHandlers = useRef({ + const handleMainEvent = useEffectEvent((event: MainEvent) => dispatchMainEvent(event, { spotifyPlayRequested: player.playRequestedTrack, playerState: player.onState, playbackAuthorizationRequired: player.onAuthorizationRequired, @@ -228,16 +227,7 @@ function App() { operationRecovered: () => dispatch({ type: 'clear-error' }), localImportComplete: (summary) => dispatch({ type: 'importComplete', summary }), startupNotice: (notice) => dispatch({ type: 'notice', notice }), - }) - mainEventHandlers.current = { - spotifyPlayRequested: player.playRequestedTrack, - playerState: player.onState, - playbackAuthorizationRequired: player.onAuthorizationRequired, - operationError: (error) => dispatch({ type: 'error', error }), - operationRecovered: () => dispatch({ type: 'clear-error' }), - localImportComplete: (summary) => dispatch({ type: 'importComplete', summary }), - startupNotice: (notice) => dispatch({ type: 'notice', notice }), - } + })) const persistSettings = useCallback((patch: SettingsPatch) => { appGateway.updateSettings(patch).catch(fail) }, [fail]) @@ -270,9 +260,8 @@ function App() { } if (state.viewKey !== browserPlayKey) return setBrowserPlayKey(undefined) - const queue = playbackQueue(displayedTracks) - const first = queue[0] - if (first && tracklistVisible) player.start(first.id, queue, { kind: 'library', source: state.source }) + const first = displayedTracks.find((track) => track.enabled) + if (first && tracklistVisible) player.start(first.id, displayedTracks, { kind: 'library', source: state.source }) }, [browserPlayKey, browseKey, displayedTracks, player, state.source, state.viewKey, tracklistVisible]) const setBrowserPanes = useCallback((browserPanes: BrowserPanes) => { for (const facet of ['cat', 'art', 'alb'] as const) { @@ -364,32 +353,32 @@ function App() { }, [playlists, state.selectedPlaylist]) useTauriInvalidationSnapshot('settings-changed', appGateway.settings, - (settings) => dispatch({ type: 'hydrateSettings', settings })) + (settings) => dispatch({ type: 'hydrateSettings', settings }), fail) useTauriInvalidationSnapshot(spotifyEvents.connectionChanged, spotifyGateway.connectionState, - (connection) => dispatch({ type: 'connection', connection })) + (connection) => dispatch({ type: 'connection', connection }), fail) useTauriInvalidationSnapshot(spotifyEvents.syncStatusChanged, spotifyGateway.syncStatus, - (status) => dispatch({ type: 'spotifySyncStatus', status })) + (status) => dispatch({ type: 'spotifySyncStatus', status }), fail) useTauriInvalidationSnapshot('lastfm-changed', lastfmGateway.accountState, - (lastfm) => dispatch({ type: 'lastfm', lastfm })) + (lastfm) => dispatch({ type: 'lastfm', lastfm }), fail) useTauriInvalidationSnapshot('lastfm-import-changed', lastfmGateway.state, (lastfmImport) => { dispatch({ type: 'lastfmImport', lastfmImport }) spotifyGateway.syncStatus().then((status) => dispatch({ type: 'spotifySyncStatus', status })).catch(fail) - }) + }, fail) useEffect(() => subscribeMainEvents( - (event) => dispatchMainEvent(event, mainEventHandlers.current), + handleMainEvent, fail, ), [fail]) - useTauriEvent('get-info', () => openInfo()) - useTauriEvent(libraryEvents.changed, () => dispatch({ type: 'refresh' })) - useTauriEvent(spotifyEvents.syncProgress, (phase) => dispatch({ type: 'syncPhase', phase: phase || undefined })) - useTauriEvent<{ tracks: number; fraction: number }>(spotifyEvents.syncProgressCount, (progress) => dispatch({ type: 'syncProgress', progress })) - useTauriEvent(spotifyEvents.playlistsChanged, () => dispatch({ type: 'playlistsRefresh' })) - useTauriEvent(libraryEvents.localImportStarted, () => dispatch({ type: 'importStarted' })) - useTauriEvent(libraryEvents.localImportFailed, () => dispatch({ type: 'importFailed' })) - useTauriEvent(libraryEvents.localDragChanged, setNativeDragActive) + useTauriEvent('get-info', () => openInfo(), fail) + useTauriEvent(libraryEvents.changed, () => dispatch({ type: 'refresh' }), fail) + useTauriEvent(spotifyEvents.syncProgress, (phase) => dispatch({ type: 'syncPhase', phase: phase || undefined }), fail) + useTauriEvent<{ tracks: number; fraction: number }>(spotifyEvents.syncProgressCount, (progress) => dispatch({ type: 'syncProgress', progress }), fail) + useTauriEvent(spotifyEvents.playlistsChanged, () => dispatch({ type: 'playlistsRefresh' }), fail) + useTauriEvent(libraryEvents.localImportStarted, () => dispatch({ type: 'importStarted' }), fail) + useTauriEvent(libraryEvents.localImportFailed, () => dispatch({ type: 'importFailed' }), fail) + useTauriEvent(libraryEvents.localDragChanged, setNativeDragActive, fail) useEffect(() => { if (!state.importStatus || state.importStatus === 'Importing local files…') return @@ -439,15 +428,6 @@ function App() { getCurrentWindow().setTitle(title).catch(fail) }, [state.source, fail]) - useEffect(() => { - const tick = simulatedPlaybackTick(state.playing?.simulated, state.playing?.isPlaying, state.playing?.trackId, playbackTracks) - if (!tick) return - const timer = window.setInterval(() => { - dispatch({ type: 'tick', ...tick }) - }, 1000) - return () => window.clearInterval(timer) - }, [state.playing?.trackId, state.playing?.isPlaying, state.playing?.simulated, playbackTracks]) - const mutate = (mutation: () => Promise) => { mutation() .then(() => dispatch({ type: 'refresh' })) @@ -558,16 +538,15 @@ function App() { else if (payload === 'toggle_zebra') updateSettings({ zebra: !state.settings.zebra }) else if (payload === 'toggle_browser') toggleBrowser() else if (payload.startsWith('theme_')) updateSettings({ theme: payload.slice(6) as Theme }) - }) + }, fail) useTauriEvent(playbackEvents.action, (payload) => { if (payload === 'play_pause') player.toggle() else player.step(payload === 'previous' ? -1 : 1) - }) - useTauriEvent('open-preferences', openPreferences) - useTauriEvent('open-setup', () => dispatch({ type: 'setup', open: true })) + }, fail) + useTauriEvent('open-preferences', openPreferences, fail) + useTauriEvent('open-setup', () => dispatch({ type: 'setup', open: true }), fail) - const onKeyDown = useRef<(event: KeyboardEvent) => void>(() => {}) - onKeyDown.current = (event: KeyboardEvent) => { + const onKeyDown = useEffectEvent((event: KeyboardEvent) => { const modalOpen = Boolean(state.info || state.preferences || state.setup || state.playbackAuthorization || playlistSubject) if (event.key === 'Escape' && modalOpen) { event.preventDefault() @@ -630,12 +609,11 @@ function App() { window.requestAnimationFrame(() => document.querySelector(`[data-facet="${activePane}"] [data-row-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })) } } - } + }) useEffect(() => { - const handler = (event: KeyboardEvent) => onKeyDown.current(event) - window.addEventListener('keydown', handler) + window.addEventListener('keydown', onKeyDown) return () => { - window.removeEventListener('keydown', handler) + window.removeEventListener('keydown', onKeyDown) window.clearTimeout(typeahead.current.timer) } }, []) @@ -955,7 +933,7 @@ export function TransportBar({ playing, progress, track, query, queryReset, scop if (next !== query) onQuery(next) } const nativeElapsed = useSyncExternalStore(progress?.subscribe ?? noProgressSubscription, progress?.getSnapshot ?? (() => playing?.elapsed ?? 0)) - const elapsed = !playing ? 0 : playing.simulated ? playing.elapsed : nativeElapsed + const elapsed = !playing ? 0 : nativeElapsed const shown = playing?.external ? { name: `${playing.name ?? 'Unknown Track'} (Spotify)`, art: playing.art ?? '', diff --git a/apps/desktop/src/LastFmImporter.tsx b/apps/desktop/src/LastFmImporter.tsx index dbbf959..283ccf0 100644 --- a/apps/desktop/src/LastFmImporter.tsx +++ b/apps/desktop/src/LastFmImporter.tsx @@ -803,7 +803,7 @@ function ImportPage({ playback, page, onApply, failed, showQueries, onRefresh, o if (uri) selectedCollectionTrackUris.add(uri) } const orderedRows = sortImportRows(projectedRows, requiredMatchIds, rowSort) - const mergeMembers = new Map(Object.entries(page.fuzzyGroups).map(([target, rows]) => [target, new Set(rows.map((row) => row.stableId))])) + const mergeMembers = new Map(Object.entries(page.fuzzyGroups).map(([target, ids]) => [target, new Set(ids)])) const mergeGroups = new Map() for (const item of orderedRows) { const target = matchedTrack(item)?.uri @@ -1076,12 +1076,12 @@ export default function LastFmImporter() { useEffect(() => { let active = true const subscription = subscribeThenSnapshot( - (install) => listen(lastfmEvents.playback, ({ payload }) => install(payload)), + (install) => listen(lastfmEvents.playback, ({ payload }) => { if (active) install(payload) }), lastfmGateway.playback, setPlayback, () => active, ).catch((reason) => { if (active) reportError(reason) }) - return () => { active = false; void subscription.then((stop) => stop?.()) } + return () => { active = false; void subscription.then((stop) => stop?.()).catch(() => {}) } }, [reportError]) const refreshGeneration = useRef(0) const pageRequestGeneration = useRef(0) @@ -1257,27 +1257,35 @@ export default function LastFmImporter() { } useEffect(() => { let active = true - const subscription = listen(lastfmEvents.changed, () => { if (shouldRefreshImportEvent(acceptAllRunning.current, queueMutationRunning.current || pendingIgnores.current.size > 0 || pendingArchives.current.size > 0)) void refresh() }) + const refreshSafely = (request: () => Promise) => { + void request().catch((reason) => { if (active) reportError(reason) }) + } + const subscription = listen(lastfmEvents.changed, () => { + if (!active || !shouldRefreshImportEvent(acceptAllRunning.current, queueMutationRunning.current || pendingIgnores.current.size > 0 || pendingArchives.current.size > 0)) return + refreshSafely(refresh) + }) const completions = listen(lastfmEvents.applyFinished, (event) => { + if (!active) return const result = parseImportApplyResult(event.payload) if (!result) { setError({ message: invalidApplyResultMessage, code: 'apply-failed', retryAt: null }) - void refreshQueueOnly() + refreshSafely(refreshQueueOnly) } else if (result.status === 'failed') { setError(result) - if (result.batchId === selectedPageRef.current) void refresh() - else void refreshQueueOnly() + if (result.batchId === selectedPageRef.current) refreshSafely(refresh) + else refreshSafely(refreshQueueOnly) } else if (!advancingApply.current && result.batchId === selectedPageRef.current) { setError(null) - void refresh() + refreshSafely(refresh) } }) const installed = subscriptionsThenSnapshot([subscription, completions], refresh, () => active) + void installed.catch((reason) => { if (active) reportError(reason) }) return () => { active = false - void installed.then((stop) => stop()) + void installed.then((stop) => stop()).catch(() => {}) } - }, [refresh, refreshQueueOnly]) + }, [refresh, refreshQueueOnly, reportError]) useEffect(() => { setPage((current) => pageWithQueuePosition(current, filteredQueue)) }, [filteredQueue]) useEffect(() => { if (!page || pageLoading || selected?.page !== page.batchId) return @@ -1300,17 +1308,17 @@ export default function LastFmImporter() { apply('system') media.addEventListener('change', onMediaChange) const subscription = subscribeThenSnapshot( - (install) => listen('appearance-changed', ({ payload }) => install(payload)), + (install) => listen('appearance-changed', ({ payload }) => { if (active) install(payload) }), appGateway.appearance, (appearance) => apply(appearance.theme), () => active, - ) + ).catch((reason) => { if (active) reportError(reason) }) return () => { active = false media.removeEventListener('change', onMediaChange) - void subscription.then((stop) => stop()) + void subscription.then((stop) => stop?.()).catch(() => {}) } - }, []) + }, [reportError]) const start = async () => { if (!validImportIntent(pendingDefaults.importContent, pendingDefaults.includeHistoricalPlayCounts)) return setBusy(true); setError(null) diff --git a/apps/desktop/src/appState.ts b/apps/desktop/src/appState.ts index 9f2211b..4a59f21 100644 --- a/apps/desktop/src/appState.ts +++ b/apps/desktop/src/appState.ts @@ -53,11 +53,6 @@ export type Action = | { type: 'scope'; scope: State['scope'] } | { type: 'selectTrack'; id: number } | { type: 'selection'; ids: Set; anchor?: number } - | { type: 'play'; id: number; queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } - | { type: 'togglePlay' } - | { type: 'step'; id: number } - | { type: 'tick'; duration: number; nextId: number } - | { type: 'seek'; elapsed: number } | { type: 'playerState'; player: PlayerState; queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } | { type: 'hydrateSettings'; settings: Settings } | { type: 'settings'; settings: Partial } @@ -169,31 +164,6 @@ export function reducer(state: State, action: Action): State { return { ...state, selectedTrackIds: new Set([action.id]), selectionAnchor: action.id } case 'selection': return { ...state, selectedTrackIds: action.ids, selectionAnchor: action.anchor } - case 'play': - return { - ...state, - selectedTrackIds: new Set([action.id]), - selectionAnchor: action.id, - playing: { - trackId: action.id, elapsed: 0, isPlaying: true, queue: action.queue, - uri: action.queue.find((track) => track.id === action.id)?.uri ?? null, - external: false, name: null, art: null, alb: null, durationSecs: null, - shuffle: state.settings.shuffle, origin: action.origin, simulated: true, - }, - } - case 'togglePlay': - return state.playing - ? { ...state, playing: { ...state.playing, isPlaying: !state.playing.isPlaying } } - : state - case 'step': - return state.playing - ? { ...state, playing: { ...state.playing, trackId: action.id, elapsed: 0, isPlaying: true } } - : state - case 'tick': - if (!state.playing?.isPlaying) return state - return state.playing.elapsed + 1 >= action.duration - ? { ...state, playing: { ...state.playing, trackId: action.nextId, elapsed: 0, isPlaying: true } } - : { ...state, playing: { ...state.playing, elapsed: state.playing.elapsed + 1 } } case 'playerState': return action.player.trackId === null && !action.player.name ? { ...state, playing: null } @@ -201,10 +171,6 @@ export function reducer(state: State, action: Action): State { ...state, playing: { ...action.player, queue: action.player.external ? emptyTracks : action.queue, origin: action.origin }, } - case 'seek': - return state.playing - ? { ...state, playing: { ...state.playing, elapsed: action.elapsed } } - : state case 'hydrateSettings': return { ...state, settings: action.settings, settingsHydrated: true } case 'settings': diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e7368aa..a1a7a8e 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -96,21 +96,40 @@ export async function subscribeInvalidationThenSnapshot( snapshot: () => Promise, install: (value: T) => void, active: () => boolean, + onError: (error: unknown) => void = () => {}, ) { let generation = 0 - let invalidated = false - const unlisten = await subscribe(() => { - invalidated = true + let stopped = false + let unlisten: (() => void) | undefined + const requestSnapshot = () => { + if (stopped || !active()) return const request = ++generation void snapshot().then((value) => { - if (active() && request === generation) install(value) + if (!stopped && active() && request === generation) install(value) + }).catch((error) => { + if (!stopped && active() && request === generation) onError(error) }) - }) - if (!active()) return unlisten + } + unlisten = await subscribe(requestSnapshot) + const stop = () => { + if (stopped) return + stopped = true + generation++ + unlisten?.() + } + if (!active()) { + stop() + return stop + } const request = ++generation - const value = await snapshot() - if (active() && !invalidated && request === generation) install(value) - return unlisten + try { + const value = await snapshot() + if (!stopped && active() && request === generation) install(value) + } catch (error) { + stop() + throw error + } + return stop } export async function subscriptionsThenSnapshot( @@ -118,9 +137,25 @@ export async function subscriptionsThenSnapshot( snapshot: () => Promise, active: () => boolean, ) { - const unlistens = await Promise.all(subscriptions) - if (active()) await snapshot() - return () => { for (const unlisten of unlistens) unlisten() } + const results = await Promise.allSettled(subscriptions) + const unlistens = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []) + const stop = () => { for (const unlisten of unlistens) unlisten() } + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure) { + stop() + throw failure.reason + } + if (!active()) { + stop() + return stop + } + try { + await snapshot() + } catch (error) { + stop() + throw error + } + return stop } export type ExternalDestination = { kind: 'lastFm' } | { kind: 'spotifyAlbum'; id: string } diff --git a/apps/desktop/src/lastfmGateway.ts b/apps/desktop/src/lastfmGateway.ts index c0b03cc..5a95092 100644 --- a/apps/desktop/src/lastfmGateway.ts +++ b/apps/desktop/src/lastfmGateway.ts @@ -10,7 +10,7 @@ export type MatchResult = { sourceId: string; searchTerm: string; confidence: 'e export type PageItem = { source: ImportSourceRow; decision: { status: 'pending' | 'done' | 'skipped' | 'ignored-album' | 'ignored-artist' }; matchResult: MatchResult | null } export type ImportPageOptions = { importContent: boolean; includeHistoricalPlayCounts: boolean; wholeAlbum: boolean; genre: string | null; rating: number | null; selectedTrackIds: string[] } export type LibraryMatchInfo = { inLibrary: boolean; albumInLibrary: boolean; genres: string[]; playCount: number | null; rating: number | null } -export type PageView = { state: LastFmImportState; batchId: number; name?: string; artist: string; album: string; customBatch?: boolean; archived?: boolean; collectionShaped?: boolean; albumLabelCount?: number; pageNumber: number; pageCount: number; rows: PageItem[]; options: ImportPageOptions; fuzzyGroups: Record; countModes: Record; resolvedCounts: Record; lockedCountModes: string[]; collection: CollectionMatchView | null; libraryMatches?: Record; suggestedGenre?: string | null } +export type PageView = { state: LastFmImportState; batchId: number; name?: string; artist: string; album: string; customBatch?: boolean; archived?: boolean; collectionShaped?: boolean; albumLabelCount?: number; pageNumber: number; pageCount: number; rows: PageItem[]; options: ImportPageOptions; fuzzyGroups: Record; countModes: Record; resolvedCounts: Record; lockedCountModes: string[]; collection: CollectionMatchView | null; libraryMatches?: Record; suggestedGenre?: string | null } export type ReviewAction = 'archive-batch' | 'unarchive-batch' | 'skip-album' | 'restore' | 'ignore-album' | 'ignore-artist' type Batch = { batchId: number; artist: string; album: string } diff --git a/apps/desktop/src/playbackProgress.ts b/apps/desktop/src/playbackProgress.ts index 4f9e030..c7dc054 100644 --- a/apps/desktop/src/playbackProgress.ts +++ b/apps/desktop/src/playbackProgress.ts @@ -26,6 +26,6 @@ export function createPlaybackProgress() { } export function onlyPlaybackProgressChanged(current: Playing | null, next: PlayerState) { - return current !== null && !current.simulated + return current !== null && (Object.keys(next) as (keyof PlayerState)[]).every(key => key === 'elapsed' || current[key] === next[key]) } diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index a6fd969..3525c6e 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -201,8 +201,7 @@ export type PlayerState = { shuffle: boolean } -// `simulated` marks fixture tracks whose URIs must never reach a real backend. -export type Playing = PlayerState & { queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin; simulated?: boolean } +export type Playing = PlayerState & { queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } export type BrowseView = { facets: { cats: string[]; arts: string[]; albs: string[] } diff --git a/apps/desktop/src/ui.ts b/apps/desktop/src/ui.ts index 59218a4..35f2d94 100644 --- a/apps/desktop/src/ui.ts +++ b/apps/desktop/src/ui.ts @@ -106,8 +106,10 @@ export const resizedPaneHeight = (startHeight: number, startY: number, clientY: export const clearedTrackRating = (inherited: number | null) => inherited === null ? null : { stars: inherited, explicit: false } +const nativePlaybackUri = (uri: string) => uri.startsWith('file:') || uri.startsWith('spotify:') + export const playbackQueue = (tracks: readonly PlaybackTrack[], requestedId?: number) => - tracks.filter((track) => track.enabled || track.id === requestedId) + tracks.filter((track) => (track.enabled || track.id === requestedId) && nativePlaybackUri(track.uri)) export const playbackStartAction = (uri: string | undefined, connected: boolean) => uri?.startsWith('spotify:') && !connected ? 'connect' as const : 'play' as const @@ -121,20 +123,6 @@ export const pendingPlaybackTarget = (prompt: PlaybackAuthorizationPrompt, track export const playbackRetryReady = (connected: boolean, playbackAuthorized: boolean, awaitingAuthorization: boolean) => connected && (!awaitingAuthorization || playbackAuthorized) -export const simulatedPlaybackTick = ( - simulated: boolean | undefined, - isPlaying: boolean | undefined, - trackId: number | null | undefined, - tracks: readonly PlaybackTrack[], -) => { - if (!simulated || !isPlaying) return null - const currentIndex = tracks.findIndex((track) => track.id === trackId) - if (currentIndex < 0) return null - const current = tracks[currentIndex] - const next = tracks[(currentIndex + 1) % tracks.length] - return { duration: current.durationSecs, nextId: next.id } -} - export const playbackOriginAction = (origin: PlaybackOrigin) => origin.kind === 'playlist' ? { type: 'playlist' as const, id: origin.id } : { type: 'source' as const, source: origin.source } diff --git a/apps/desktop/src/viewShared.tsx b/apps/desktop/src/viewShared.tsx index ed40f08..c728a8d 100644 --- a/apps/desktop/src/viewShared.tsx +++ b/apps/desktop/src/viewShared.tsx @@ -98,7 +98,7 @@ export function ContextMenu({ x, y, onClose, children }: { x: number; y: number; }}>{Children.map(children, (child) => { if (!isValidElement(child) || child.type !== 'button') return child const button = child as ReactElement> - return cloneElement(button, { role: 'menuitem', tabIndex: -1, 'aria-disabled': button.props.disabled || undefined }) + return cloneElement(button, { role: button.props.role ?? 'menuitem', tabIndex: -1, 'aria-disabled': button.props.disabled || undefined }) })} } @@ -109,7 +109,7 @@ export function CheckboxMenu({ x, y, onClose, items, children }: { onClose: () => void items: { key: string; label: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }[] }) { - return {children}{children &&
}{items.map((item) => )}
+ return {children}{children &&
}{items.map((item) => )}
} export function RatingStars({ rating, explicit = false, onRate }: { rating: number | null; explicit?: boolean; onRate?: (stars: number) => void }) { return diff --git a/apps/desktop/test/gateways.test.ts b/apps/desktop/test/gateways.test.ts index 5183f20..58dedd8 100644 --- a/apps/desktop/test/gateways.test.ts +++ b/apps/desktop/test/gateways.test.ts @@ -2,10 +2,10 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { createAppGateway } from '../src/appGateway.ts' -import { createLibraryGateway, libraryEvents } from '../src/libraryGateway.ts' -import { createLastFmGateway, lastfmEvents, type ImportPageOptions } from '../src/lastfmGateway.ts' -import { createPlaybackGateway, playbackEvents } from '../src/playbackGateway.ts' -import { createSpotifyGateway, spotifyEvents } from '../src/spotifyGateway.ts' +import { createLibraryGateway } from '../src/libraryGateway.ts' +import { createLastFmGateway, type ImportPageOptions } from '../src/lastfmGateway.ts' +import { createPlaybackGateway } from '../src/playbackGateway.ts' +import { createSpotifyGateway } from '../src/spotifyGateway.ts' import { createMainEventSubscription, dispatchMainEvent, subscribeInvalidationThenSnapshot, subscribeThenSnapshot, subscriptionsThenSnapshot, type Invoker, type MainEvent } from '../src/ipc.ts' import type { SettingsPatch } from '../src/types.ts' @@ -73,6 +73,69 @@ test('invalidation snapshot ignores stale hydration after an event', async () => assert.deepEqual(installed, ['current']) }) +test('invalidation arriving while registration resolves still installs the initial snapshot', async () => { + let invalidate!: () => void + let resolveSubscription!: (unlisten: () => void) => void + const resolvers: Array<(value: string) => void> = [] + const installed: string[] = [] + const subscription = subscribeInvalidationThenSnapshot( + (handler) => { + invalidate = handler + return new Promise((resolve) => { resolveSubscription = resolve }) + }, + () => new Promise((resolve) => resolvers.push(resolve)), + (value) => installed.push(value), + () => true, + ) + invalidate() + resolveSubscription(() => {}) + await Promise.resolve() + resolvers[1]('current') + resolvers[0]('stale') + await subscription + assert.deepEqual(installed, ['current']) +}) + +test('invalidation snapshot reports event failures and ignores them after cleanup', async () => { + let invalidate!: () => void + const resolvers: Array<(value: string) => void> = [] + const rejecters: Array<(reason: unknown) => void> = [] + let unlistened = false + const errors: unknown[] = [] + const subscription = subscribeInvalidationThenSnapshot( + async (handler) => { invalidate = handler; return () => { unlistened = true } }, + () => new Promise((resolve, reject) => { resolvers.push(resolve); rejecters.push(reject) }), + () => assert.fail('failed snapshot must not install'), + () => true, + (error) => errors.push(error), + ) + await Promise.resolve() + invalidate() + rejecters[1](new Error('event failed')) + await Promise.resolve() + await Promise.resolve() + assert.equal(errors.length, 1) + resolvers[0]('initial') + const stop = await subscription + stop() + assert.equal(unlistened, true) + invalidate() + await Promise.resolve() + assert.equal(errors.length, 1) + assert.equal(rejecters.length, 2) +}) + +test('invalidation snapshot unregisters when initial hydration fails', async () => { + let unlistened = false + await assert.rejects(subscribeInvalidationThenSnapshot( + async () => () => { unlistened = true }, + async () => { throw new Error('initial snapshot failed') }, + () => assert.fail('failed hydration must not install'), + () => true, + ), /initial snapshot failed/) + assert.equal(unlistened, true) +}) + test('main event dispatch is exhaustive over the tagged contract', () => { const received: string[] = [] const handlers = { @@ -170,6 +233,30 @@ test('all importer listeners are installed before hydration starts', async () => await installed }) +test('partial listener registration cleans up every listener that succeeded', async () => { + let firstStop = false + const installed = subscriptionsThenSnapshot( + [ + Promise.resolve(() => { firstStop = true }), + Promise.reject(new Error('registration failed')), + ], + async () => assert.fail('hydration must not start after registration failure'), + () => true, + ) + await assert.rejects(installed, /registration failed/) + assert.equal(firstStop, true) +}) + +test('listener registration cleans up when hydration fails', async () => { + let stopped = false + await assert.rejects(subscriptionsThenSnapshot( + [Promise.resolve(() => { stopped = true })], + async () => { throw new Error('hydration failed') }, + () => true, + ), /hydration failed/) + assert.equal(stopped, true) +}) + test('Last.fm gateway preserves every command name and camel-case argument', async () => { const calls: Array<[string, Record | undefined]> = [] const invoke: Invoker = async (command: string, args?: Record) => { @@ -272,14 +359,6 @@ test('app gateway preserves shell command names and argument shapes', async () = ]) }) -test('Last.fm gateway owns the consumed event names', () => { - assert.deepEqual(lastfmEvents, { - playback: 'lastfm-import-playback', - changed: 'lastfm-import-changed', - applyFinished: 'lastfm-import-apply-finished', - }) -}) - test('library gateway preserves command names and argument shapes', async () => { const calls: Array<[string, Record | undefined]> = [] const invoke: Invoker = async (command: string, args?: Record) => { @@ -325,15 +404,6 @@ test('library gateway preserves command names and argument shapes', async () => ]) }) -test('library gateway owns the consumed event names', () => { - assert.deepEqual(libraryEvents, { - changed: 'library-changed', - localImportStarted: 'local-import-started', - localImportFailed: 'local-import-failed', - localDragChanged: 'local-drag-changed', - }) -}) - test('playback gateway preserves every command name and argument shape', async () => { const calls: Array<[string, Record | undefined]> = [] const invoke: Invoker = async (command: string, args?: Record) => { @@ -364,12 +434,6 @@ test('playback gateway preserves every command name and argument shape', async ( ]) }) -test('playback gateway owns the consumed event names', () => { - assert.deepEqual(playbackEvents, { - action: 'player-action', - }) -}) - test('Spotify gateway preserves every command name and camel-case argument', async () => { const calls: Array<[string, Record | undefined]> = [] const invoke: Invoker = async (command: string, args?: Record) => { @@ -439,13 +503,3 @@ test('Spotify gateway preserves every command name and camel-case argument', asy ['open_spotify_playlist', { id: 'playlist-id', target: 'web' }], ]) }) - -test('Spotify gateway owns the consumed lifecycle event names', () => { - assert.deepEqual(spotifyEvents, { - connectionChanged: 'connection-changed', - syncProgress: 'sync-progress', - syncProgressCount: 'sync-progress-count', - syncStatusChanged: 'spotify-sync-status-changed', - playlistsChanged: 'playlists-changed', - }) -}) diff --git a/apps/desktop/test/interactions.test.tsx b/apps/desktop/test/interactions.test.tsx index 090d9a2..f41de93 100644 --- a/apps/desktop/test/interactions.test.tsx +++ b/apps/desktop/test/interactions.test.tsx @@ -7,15 +7,21 @@ import type { AlbumPageView, ArtistPageView, BrowseView, DecisionTrack, LastFmIm import type { MainEvent } from '../src/ipc.ts' const invokeMock = vi.hoisted(() => vi.fn<(command: string, args?: Record) => Promise>(async () => null)) -const nativeEventHandlers = vi.hoisted(() => new Map void>()) +const nativeEventHandlers = vi.hoisted(() => new Map void>>()) vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock, Channel: class { onmessage: (event: unknown) => void; constructor(onmessage: (event: unknown) => void) { this.onmessage = onmessage } }, })) vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async (name: string, handler: (event: { payload: unknown }) => void) => { - nativeEventHandlers.set(name, (payload) => handler({ payload })) - return () => nativeEventHandlers.delete(name) + const listener = (payload: unknown) => handler({ payload }) + const listeners = nativeEventHandlers.get(name) ?? new Set() + listeners.add(listener) + nativeEventHandlers.set(name, listeners) + return () => { + listeners.delete(listener) + if (!listeners.size) nativeEventHandlers.delete(name) + } }), })) vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ onDragDropEvent: vi.fn(async () => () => {}), setTitle: vi.fn(async () => {}) }) })) @@ -53,7 +59,9 @@ beforeEach(() => { }) async function emitNativeEvent(name: string, payload: unknown) { - await act(async () => nativeEventHandlers.get(name)?.(payload)) + await act(async () => { + for (const handler of nativeEventHandlers.get(name) ?? []) handler(payload) + }) } async function render(element: React.ReactNode) { @@ -236,7 +244,7 @@ describe('mounted native interaction boundaries', () => { let playback: PlayerState = { trackId: null, uri: null, elapsed: 0, isPlaying: false, external: false, name: null, art: null, alb: null, durationSecs: 180, shuffle: false } const publishPlayback = () => { channel.onmessage({ type: 'playerState', payload: playback }) - nativeEventHandlers.get('lastfm-import-playback')?.({ uri: playback.uri, isPlaying: playback.isPlaying }) + for (const handler of nativeEventHandlers.get('lastfm-import-playback') ?? []) handler({ uri: playback.uri, isPlaying: playback.isPlaying }) } let connected = initiallyConnected let channel: { onmessage: (event: MainEvent) => void } @@ -392,12 +400,12 @@ describe('mounted native interaction boundaries', () => { expect(nativeEventHandlers.has('lastfm-import-playback')).toBe(false) }) - it('starts a facet with its first enabled visible track', async () => { + it('reports unsupported fixture playback instead of simulating it', async () => { const tracks = [ { ...track(1, 'Excluded'), uri: 'fixture:track:excluded', enabled: false }, { ...track(2, 'Included'), uri: 'fixture:track:included', enabled: true }, ] - let browse: BrowseView = { + const browse: BrowseView = { facets: { cats: ['Rock'], arts: ['Artist'], albs: ['Album'] }, tracks, albumRating: null, @@ -429,15 +437,9 @@ describe('mounted native interaction boundaries', () => { await waitFor(() => expect(view.querySelector('[data-facet="cat"] [data-row-index="1"]')).not.toBeNull()) await act(async () => view.querySelector('[data-facet="cat"] [data-row-index="1"]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) - await waitFor(() => expect(view.querySelector('.lcd-copy .marquee')?.textContent).toBe('Included')) - - browse = { - ...browse, - tracks: browse.tracks.map((track, index) => ({ ...track, id: index + 3, name: `No Playback ${index + 1}`, enabled: false })), - } - await act(async () => view.querySelector('[data-facet="art"] [data-row-index="1"]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) - await waitFor(() => expect(view.querySelector('[data-track-id="3"]')?.textContent).toContain('No Playback 1')) - expect(view.querySelector('.lcd-copy .marquee')?.textContent).toBe('Included') + await waitFor(() => expect(view.querySelector('.error-banner')?.textContent).toBe('Unsupported playback URI: fixture:track:included')) + expect(view.querySelector('.lcd-copy .marquee')?.textContent).not.toBe('Included') + expect(invokeMock.mock.calls.some(([command]) => command === 'play_tracks')).toBe(false) }) it('keeps unavailable artist follow state retryable and rejects a late stale retry', async () => { @@ -589,7 +591,7 @@ describe('mounted native interaction boundaries', () => { const openMenu = async (target: HTMLElement) => act(async () => target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 250, clientY: 170 }))) for (const target of [view.querySelector(`[data-facet="${facet}"] .column-header`)!, view.querySelector(`[data-facet="${facet}"] button[data-row-index="0"]`)!, facetRow('cat', 'Rock')]) { await openMenu(target) - expect(view.querySelector('[role="menu"] button')).toBeNull() + expect(view.querySelector('[role="menu"] > [role="menuitem"]')).toBeNull() expect(view.querySelector('[role="menu"] hr')).toBeNull() await act(async () => key(view.querySelector('[role="menu"]')!, 'Escape')) } @@ -606,7 +608,14 @@ describe('mounted native interaction boundaries', () => { expect([...menu.querySelectorAll('[role="menuitemcheckbox"]')].map((item) => item.textContent)).toEqual(['Genre', 'Artist', 'Album']) expect(document.activeElement).toBe(navigation) await act(async () => key(navigation, 'ArrowDown')) - expect(document.activeElement).toBe(menu.querySelector('[role="menuitemcheckbox"]')) + const checkbox = menu.querySelector('[role="menuitemcheckbox"]')! + expect(document.activeElement).toBe(checkbox) + checkbox.focus() + await act(async () => { + expect(key(checkbox, 'Enter').defaultPrevented).toBe(false) + checkbox.click() + }) + await waitFor(() => expect(menu.querySelector('[role="menuitemcheckbox"]')?.getAttribute('aria-checked')).toBe('false')) if (process.env.RETUNE_FACET_PREVIEW) { const { writeFileSync, readFileSync } = await import('node:fs') writeFileSync(process.env.RETUNE_FACET_PREVIEW + '-' + facet + '.html', '' + view.innerHTML + '') @@ -1820,7 +1829,7 @@ describe('mounted native interaction boundaries', () => { }) const sortedRows = [rows[0], rows[2], rows[1]] const other = { ...base, source: { ...base.source, stableId: 'other', track: 'Another song' }, matchResult: { ...base.matchResult, trackMatches: { other: target } } } - let page = { ...fixtures.collectionPage, rows: [...rows, other], options: { ...fixtures.collectionPage.options, selectedTrackIds: [...rows, other].map(({ source }) => source.stableId) }, fuzzyGroups: { [target]: rows.map(({ source }) => source) }, resolvedCounts: { [target]: 121 } } + let page = { ...fixtures.collectionPage, rows: [...rows, other], options: { ...fixtures.collectionPage.options, selectedTrackIds: [...rows, other].map(({ source }) => source.stableId) }, fuzzyGroups: { [target]: rows.map(({ source }) => source.stableId) }, resolvedCounts: { [target]: 121 } } invokeMock.mockImplementation(async (command, args) => { if (command === 'lastfm_import_state') return fixtures.state if (command === 'lastfm_import_queue') return { cursor: 0, items: [fixtures.queue[0]], total: 1, nextCursor: null } @@ -1828,7 +1837,7 @@ describe('mounted native interaction boundaries', () => { if (command === 'lastfm_import_select_matches') return page if (command === 'lastfm_import_options') { const options = args?.options as typeof page.options - page = { ...page, options, fuzzyGroups: options.selectedTrackIds.length === 4 ? { [target]: rows.map(({ source }) => source) } : {} } + page = { ...page, options, fuzzyGroups: options.selectedTrackIds.length === 4 ? { [target]: rows.map(({ source }) => source.stableId) } : {} } return null } if (command === 'lastfm_import_review') return fixtures.state @@ -1900,7 +1909,7 @@ describe('mounted native interaction boundaries', () => { decision: { status: id === 'done' ? 'done' as const : id === 'ignored' ? 'ignored-album' as const : 'pending' as const }, matchResult: { ...base.matchResult, trackMatches: { [id]: target } }, })) - const page = { ...fixtures.collectionPage, rows, options: { ...fixtures.collectionPage.options, selectedTrackIds: ['pending-a', 'pending-b'] }, fuzzyGroups: { [target]: rows.filter((row) => !['ignored-album', 'ignored-artist'].includes(row.decision.status)).map(({ source }) => source) }, resolvedCounts: { [target]: 12 } } + const page = { ...fixtures.collectionPage, rows, options: { ...fixtures.collectionPage.options, selectedTrackIds: ['pending-a', 'pending-b'] }, fuzzyGroups: { [target]: rows.filter((row) => !['ignored-album', 'ignored-artist'].includes(row.decision.status)).map(({ source }) => source.stableId) }, resolvedCounts: { [target]: 12 } } invokeMock.mockImplementation(async (command) => { if (command === 'lastfm_import_state') return fixtures.state if (command === 'lastfm_import_queue') return { cursor: 0, items: [fixtures.queue[0]], total: 1, nextCursor: null } diff --git a/apps/desktop/test/ui.test.ts b/apps/desktop/test/ui.test.ts index ffd1804..b873b60 100644 --- a/apps/desktop/test/ui.test.ts +++ b/apps/desktop/test/ui.test.ts @@ -6,7 +6,7 @@ import { initialState, reducer, type Action } from '../src/appState.ts' import type { BrowseView, PlaybackTrack, PlayOutcome, Selection, Settings, SpotifyResults } from '../src/types.ts' import { createSpotifySearchState, expandSpotifySearchGroup, failSpotifySearchGroup, moreSpotifySearchLabel, receiveSpotifySearchPage, replaceSpotifySearchResults, resetSpotifySearchQuery, retrySpotifySearchGroup, setSpotifySearchTab, spotifyMembership, spotifySearchGroupHeader, spotifySearchPendingPageKey } from '../src/spotifySearch.ts' import { activeImportQueue, applyCurrentImportRefresh, beginImportRefresh, canHandleImportShortcut, collectionAlbumActionLabel, collectionAlbumTrackStatuses, collectionAmbiguousChoices, collectionCoverageStatus, collectionDialogInitialState, collectionDialogScreen, collectionDialogTransition, collectionImportBranch, collectionPreviewCoverageCopy, collectionSuggestion, downloadAction, filterImportQueue, handleImportQueueTab, importAlbumActionAdvances, importApplyErrorCode, importCountMergePresentation, importDownloadCopy, importDownloadPercent, importDownloadProgressLabel, importEmptyPageMessage, importHistoryBreadcrumb, importQueueHighlightIndex, importQueueTabTarget, importQueueVisibleRange, importStatusText, isCurrentImportPageResponse, loadSelectedImportPage, mergeReviewBatchDraft, moveImportNavigationRow, moveImportQueueIndex, nextRemainingImportQueue, parseImportApplyResult, pickerCandidates, pickerSelectedUri, projectAcknowledgedImportApply, resolveImportCount, runCheckedImportMutation, selectImportRows, selectedCollectionAlbumUris, selectedImportTrackConfidence, shouldRefreshImportEvent, showsImportRemaining, sortImportQueue, sortImportRows, spotifyLimitCountdown, stablePartitionImportRows, strongImportAlbumMatch, trackPickerQuery, validImportIntent, type ImportQueueItem, type ImportSourceRow, type ReviewState } from '../src/lastfmImportState.ts' -import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseViewForRequest, cancelTrackInfoLoad, clearedTrackRating, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, dialogTabTarget, entityRequestGeneration, facetLabel, failedPlaylistRows, insertionIndexAtY, isCurrentTrack, LIBRARY_DEFAULT_COLUMN_ORDER, LIBRARY_DEFAULT_HIDDEN_COLUMNS, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, menuPosition, mergeByUri, moveBefore, moveToIndex, normalizeZoom, overlayEditTargets, pendingEntities, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, rememberSelection, resolvedPlaylistRows, restoreSelection, resizedColumnWidth, resizedPaneHeight, selectionAfterFacet, simulatedPlaybackTick, staleSelectionFacet, SYNTHETIC_BASE, visibleColumnOrder } from '../src/ui.ts' +import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseViewForRequest, cancelTrackInfoLoad, clearedTrackRating, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, dialogTabTarget, entityRequestGeneration, facetLabel, failedPlaylistRows, insertionIndexAtY, isCurrentTrack, LIBRARY_DEFAULT_COLUMN_ORDER, LIBRARY_DEFAULT_HIDDEN_COLUMNS, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, menuPosition, mergeByUri, moveBefore, moveToIndex, normalizeZoom, overlayEditTargets, pendingEntities, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, rememberSelection, resolvedPlaylistRows, restoreSelection, resizedColumnWidth, resizedPaneHeight, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE, visibleColumnOrder } from '../src/ui.ts' const searchPage = (overrides: Partial = {}): SpotifyResults => ({ artists: { items: Array.from({ length: 10 }, (_, index) => ({ id: `artist-${index}`, name: `Artist ${index}`, descriptor: '', imageUrl: null })), total: 21, nextOffset: 10 }, @@ -699,8 +699,8 @@ test('Spotify search pending pages are not reused after returning to the same qu test('pending browse retains only compatible rows and facet candidates', () => { const broadQueue: PlaybackTrack[] = [ - { id: 1, uri: 'fixture:track:1', name: 'Welcome', art: 'Artist', alb: 'Broad Album', durationSecs: 180, enabled: true }, - { id: 2, uri: 'fixture:track:2', name: 'Americana', art: 'Artist', alb: 'Broad Album', durationSecs: 200, enabled: true }, + { id: 1, uri: 'spotify:track:1', name: 'Welcome', art: 'Artist', alb: 'Broad Album', durationSecs: 180, enabled: true }, + { id: 2, uri: 'file:///music/americana.mp3', name: 'Americana', art: 'Artist', alb: 'Broad Album', durationSecs: 200, enabled: true }, ] const baseSelection = {} const resolvedKey = browseRequestKey('music', baseSelection, '', 'library') @@ -770,7 +770,12 @@ test('navigation transitions preserve the active playback queue and origin', () { type: 'spotifyNavigate', entry: { kind: 'artist', id: 'artist-a' } }, { type: 'playlist' }, ] - let state = reducer(initialState, { type: 'play', id: 2, queue, origin }) + let state = reducer(initialState, { + type: 'playerState', + player: { trackId: 2, uri: 'spotify:track:two', elapsed: 0, isPlaying: true, external: false, name: null, art: null, alb: null, durationSecs: 180, shuffle: false }, + queue, + origin, + }) for (const transition of transitions) { state = reducer(state, transition) @@ -1051,14 +1056,14 @@ test('clearing a track rating reveals its inherited album rating', () => { }) test('ordinary queues skip exclusions but an explicit start still plays one', () => { - const tracks = [ - { id: 1, enabled: true }, - { id: 2, enabled: false }, - { id: 3, enabled: true }, - ] as never - assert.deepEqual(playbackQueue(tracks).map((track) => track.id), [1, 3]) + const tracks: PlaybackTrack[] = [ + { id: 1, uri: 'spotify:track:one', name: 'One', art: 'Artist', alb: 'Album', durationSecs: 1, enabled: true }, + { id: 2, uri: 'fixture:track:two', name: 'Two', art: 'Artist', alb: 'Album', durationSecs: 1, enabled: true }, + { id: 3, uri: 'file:///three.mp3', name: 'Three', art: 'Artist', alb: 'Album', durationSecs: 1, enabled: false }, + ] + assert.deepEqual(playbackQueue(tracks).map((track) => track.id), [1]) assert.deepEqual(playbackQueue(tracks.map((track) => ({ ...track, enabled: false }))).map((track) => track.id), []) - assert.deepEqual(playbackQueue(tracks, 2).map((track) => track.id), [1, 2, 3]) + assert.deepEqual(playbackQueue(tracks, 3).map((track) => track.id), [1, 3]) }) test('playlist highlights require both the synthetic id and Spotify URI', () => { @@ -1129,18 +1134,6 @@ test('only the latest play intent can populate playback authorization and retry assert.equal(pendingPlaybackTarget({ reason: 'missing', message: 'Current native event.', targetTrackId: SYNTHETIC_BASE, targetTrackUri: 'spotify:track:b' }, tracksB), SYNTHETIC_BASE) }) -test('simulated playback ticks stop when playback pauses or switches to a live backend', () => { - const tracks: PlaybackTrack[] = [ - { id: 1, uri: 'fixture:one', name: 'One', art: 'Artist', alb: 'Album', durationSecs: 180, enabled: true }, - { id: 2, uri: 'fixture:two', name: 'Two', art: 'Artist', alb: 'Album', durationSecs: 240, enabled: true }, - ] - assert.deepEqual(simulatedPlaybackTick(true, true, 1, tracks), { duration: 180, nextId: 2 }) - assert.equal(simulatedPlaybackTick(true, false, 1, tracks), null) - assert.equal(simulatedPlaybackTick(false, true, 1, tracks), null) - assert.equal(simulatedPlaybackTick(undefined, true, 1, tracks), null) - assert.equal(simulatedPlaybackTick(true, true, 3, tracks), null) -}) - test('track and disc sorts keep multi-disc albums in playback order', () => { const track = (discNo: number | null, trackNo: number) => ({ discNo, trackNo } as never) const tracks = [track(2, 1), track(1, 2), track(null, 1), track(1, 3)] diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 6469536..e0f146e 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -10,6 +10,6 @@ export default defineConfig({ strictPort: true, }, build: { - target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13', + target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : process.env.TAURI_ENV_PLATFORM === 'macos' ? 'safari18' : 'safari13', }, }) diff --git a/crates/retune-spotify/src/client.rs b/crates/retune-spotify/src/client.rs index 8435c02..04f07c2 100644 --- a/crates/retune-spotify/src/client.rs +++ b/crates/retune-spotify/src/client.rs @@ -90,7 +90,7 @@ fn spotify_path_id<'a>(id: &'a str, kind: &str) -> Result<&'a str> { pub fn fake_client( responses: impl IntoIterator, scopes: &str, -) -> SpotifyClient { +) -> SpotifyClient { SpotifyClient::new( "client", FakeTransport::new(responses), @@ -108,10 +108,11 @@ pub fn fake_client( mod tests { use std::collections::VecDeque; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::thread; use super::*; use crate::auth::TOKEN_URL; - use crate::tokens::{CachedTokenStore, InMemoryTokenStore, PlaybackCredentials}; + use crate::tokens::{CachedTokenStore, InMemoryTokenStore, PlaybackCredentials, TokenStore}; use tokio::sync::Barrier; struct OverlapTransport { @@ -180,6 +181,87 @@ mod tests { } } + struct ThreadCheckingStore { + inner: Arc, + executor: thread::ThreadId, + } + + impl TokenStore for ThreadCheckingStore { + fn load(&self) -> Result> { + assert_ne!( + thread::current().id(), + self.executor, + "token load ran on the async executor" + ); + self.inner.load() + } + + fn save(&self, tokens: &Tokens) -> Result<()> { + assert_ne!( + thread::current().id(), + self.executor, + "token save ran on the async executor" + ); + self.inner.save(tokens) + } + + fn clear(&self) -> Result<()> { + assert_ne!( + thread::current().id(), + self.executor, + "token clear ran on the async executor" + ); + self.inner.clear() + } + + fn replace_if_current(&self, expected: &Tokens, tokens: &Tokens) -> Result { + assert_ne!( + thread::current().id(), + self.executor, + "token compare-and-replace ran on the async executor" + ); + self.inner.replace_if_current(expected, tokens) + } + } + + #[derive(Default)] + struct BearerAwareTransport { + requests: Mutex>, + } + + impl BearerAwareTransport { + fn requests(&self) -> Vec { + self.requests.lock().expect("fake mutex poisoned").clone() + } + } + + impl Transport for BearerAwareTransport { + fn send(&self, request: Request) -> SendFuture<'_> { + let response = if request.url == TOKEN_URL { + Response::json( + 200, + serde_json::json!({"access_token": "new", "expires_in": 3600}), + ) + } else if request + .headers + .get("authorization") + .is_some_and(|value| value == "Bearer old") + { + Response::json(401, serde_json::json!({"error": "expired"})) + } else { + Response::json(200, serde_json::json!({"items": [], "next": null})) + }; + Box::pin(async move { + self.requests + .lock() + .map_err(|error| Error::Transport(error.to_string()))? + .push(request); + tokio::task::yield_now().await; + Ok(response) + }) + } + } + fn tokens() -> InMemoryTokenStore { InMemoryTokenStore::new(Some(Tokens { access: "old".into(), @@ -541,6 +623,32 @@ mod tests { assert_eq!(*client.request_not_before.lock().await, Some(cooldown)); } + #[tokio::test(flavor = "current_thread")] + async fn access_token_refresh_runs_token_store_io_off_executor() { + let stored = Tokens { + access: "access".into(), + refresh: "refresh".into(), + expires_at: 0, + scopes: "streaming".into(), + playback_credentials: None, + }; + let inner = Arc::new(InMemoryTokenStore::new(Some(stored))); + let client = SpotifyClient::new( + "client", + FakeTransport::new([Response::json( + 200, + serde_json::json!({"access_token": "refreshed", "expires_in": 3600}), + )]), + ThreadCheckingStore { + inner: Arc::clone(&inner), + executor: thread::current().id(), + }, + ); + + assert_eq!(client.access_token().await.unwrap(), "refreshed"); + assert_eq!(inner.load().unwrap().unwrap().access, "refreshed"); + } + #[tokio::test] async fn malformed_refresh_tokens_do_not_change_storage() { for response in [ @@ -1522,7 +1630,7 @@ mod tests { } #[tokio::test] - async fn playlist_unfollow_sends_exact_delete() { + async fn playlist_unfollow_removes_playlist_from_library() { let client = SpotifyClient::new( "client", FakeTransport::new([Response::json(200, serde_json::Value::Null)]), @@ -1534,11 +1642,13 @@ mod tests { let requests = client.transport().requests(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].method, Method::Delete); + let url = url::Url::parse(&requests[0].url).unwrap(); + assert_eq!(url.path(), "/v1/me/library"); + assert!(requests[0].body.is_empty()); assert_eq!( - requests[0].url, - format!("{API_BASE}/playlists/playlist/followers") + url.query_pairs().collect::>(), + [("uris".into(), "spotify:playlist:playlist".into())] ); - assert!(requests[0].body.is_empty()); } #[tokio::test] @@ -1690,15 +1800,7 @@ mod tests { #[tokio::test] async fn concurrent_unauthorized_requests_refresh_once() { - let transport = FakeTransport::new([ - Response::json(401, serde_json::json!({"error": "expired"})), - Response::json( - 200, - serde_json::json!({"access_token": "new", "expires_in": 3600}), - ), - Response::json(200, serde_json::json!({"items": [], "next": null})), - Response::json(200, serde_json::json!({"items": [], "next": null})), - ]); + let transport = BearerAwareTransport::default(); let client = SpotifyClient::new("client", transport, tokens()); let (left, right) = tokio::join!(client.saved_tracks(0, 1), client.saved_tracks(1, 1)); left.unwrap(); diff --git a/crates/retune-spotify/src/client/endpoints.rs b/crates/retune-spotify/src/client/endpoints.rs index 4380fa4..a98237c 100644 --- a/crates/retune-spotify/src/client/endpoints.rs +++ b/crates/retune-spotify/src/client/endpoints.rs @@ -1,6 +1,6 @@ use super::*; -impl SpotifyClient { +impl SpotifyClient { pub async fn saved_tracks(&self, offset: u32, limit: u32) -> Result> { let page: Page = self.get(&paged("/me/tracks", offset, limit)).await?; let mut catalog = self.catalog.lock().expect("Spotify catalog mutex poisoned"); @@ -49,12 +49,8 @@ impl SpotifyClient { pub async fn unfollow_playlist(&self, playlist_id: &str) -> Result<()> { let playlist_id = spotify_path_id(playlist_id, "playlist")?; - self.empty( - Method::Delete, - &format!("/playlists/{playlist_id}/followers"), - Vec::new(), - ) - .await + self.remove_from_library(&[format!("spotify:playlist:{playlist_id}")]) + .await } pub async fn playlist_tracks( diff --git a/crates/retune-spotify/src/client/request.rs b/crates/retune-spotify/src/client/request.rs index 489a7da..93dfbbf 100644 --- a/crates/retune-spotify/src/client/request.rs +++ b/crates/retune-spotify/src/client/request.rs @@ -1,17 +1,21 @@ use super::*; -pub struct SpotifyClient { +pub struct SpotifyClient { pub(super) client_id: String, pub(super) transport: T, - pub(super) tokens: S, + pub(super) tokens: Arc, pub(super) catalog: Arc>, pub(super) request_counts: Mutex>, pub(super) refresh_lock: AsyncMutex<()>, pub(super) request_not_before: AsyncMutex>, } -impl SpotifyClient { - pub fn new(client_id: impl Into, transport: T, tokens: S) -> Self { +impl SpotifyClient { + pub fn new( + client_id: impl Into, + transport: T, + tokens: impl TokenStore + 'static, + ) -> Self { Self::new_with_catalog( client_id, transport, @@ -23,13 +27,13 @@ impl SpotifyClient { pub fn new_with_catalog( client_id: impl Into, transport: T, - tokens: S, + tokens: impl TokenStore + 'static, catalog: Arc>, ) -> Self { Self { client_id: client_id.into(), transport, - tokens, + tokens: Arc::new(tokens), catalog, request_counts: Mutex::default(), refresh_lock: AsyncMutex::new(()), @@ -45,8 +49,8 @@ impl SpotifyClient { &self.transport } - pub fn token_store(&self) -> &S { - &self.tokens + pub fn token_store(&self) -> &dyn TokenStore { + self.tokens.as_ref() } pub fn reset_request_counts(&self) { @@ -64,11 +68,34 @@ impl SpotifyClient { } pub async fn access_token(&self) -> Result { - let stored = self.tokens.load()?.ok_or(Error::MissingToken)?; + let stored = self.load_tokens().await?.ok_or(Error::MissingToken)?; if token_expired(stored.expires_at, unix_now()) { self.refresh_token(&stored.access).await?; } - Ok(self.tokens.load()?.ok_or(Error::MissingToken)?.access) + Ok(self.load_tokens().await?.ok_or(Error::MissingToken)?.access) + } + + /// Load tokens without blocking the async runtime. + pub async fn load_tokens(&self) -> Result> { + let store = Arc::clone(&self.tokens); + tokio::task::spawn_blocking(move || store.load()) + .await + .map_err(|error| Error::TokenStore(format!("token store task failed: {error}")))? + } + + /// Replace tokens if the complete record is still `expected` without + /// blocking the async runtime. + pub async fn replace_tokens_if_current( + &self, + expected: &Tokens, + replacement: &Tokens, + ) -> Result { + let store = Arc::clone(&self.tokens); + let expected = expected.clone(); + let replacement = replacement.clone(); + tokio::task::spawn_blocking(move || store.replace_if_current(&expected, &replacement)) + .await + .map_err(|error| Error::TokenStore(format!("token store task failed: {error}")))? } pub(super) async fn get(&self, path: &str) -> Result { @@ -100,7 +127,7 @@ impl SpotifyClient { let mut rate_retries = 0; let mut server_retries = 0; loop { - let access = self.tokens.load()?.ok_or(Error::MissingToken)?.access; + let access = self.load_tokens().await?.ok_or(Error::MissingToken)?.access; let response = match self .send_api_request(method, path, body.clone(), &access) .await @@ -230,7 +257,7 @@ impl SpotifyClient { async fn refresh_token(&self, stale_access: &str) -> Result<()> { let _guard = self.refresh_lock.lock().await; - let stored = self.tokens.load()?.ok_or(Error::MissingToken)?; + let stored = self.load_tokens().await?.ok_or(Error::MissingToken)?; if stored.access != stale_access { return Ok(()); } @@ -251,11 +278,14 @@ impl SpotifyClient { scopes: expected.scopes.clone(), playback_credentials: expected.playback_credentials.clone(), }; - if self.tokens.replace_if_current(&expected, &replacement)? { + if self + .replace_tokens_if_current(&expected, &replacement) + .await? + { log::info!("Refreshed Spotify access token"); break; } - let Some(current) = self.tokens.load()? else { + let Some(current) = self.load_tokens().await? else { log::info!("Discarded stale Spotify token refresh after disconnect"); break; }; diff --git a/crates/retune-spotify/src/tokens.rs b/crates/retune-spotify/src/tokens.rs index 72917b3..aaaa22f 100644 --- a/crates/retune-spotify/src/tokens.rs +++ b/crates/retune-spotify/src/tokens.rs @@ -5,9 +5,16 @@ use std::{ sync::{Arc, Mutex, OnceLock}, }; +#[cfg(all(test, unix))] +use std::cell::Cell; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; +#[cfg(all(test, unix))] +thread_local! { + static FAIL_NEXT_PARENT_SYNC: Cell = const { Cell::new(false) }; +} + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use chacha20poly1305::{ ChaCha20Poly1305, Nonce, @@ -171,6 +178,7 @@ impl TokenStore for CachedTokenStore { fn save(&self, tokens: &Tokens) -> Result<()> { let mut cache = self.cache.lock().map_err(token_error)?; + *cache = None; self.inner.save(tokens)?; *cache = Some(Some(tokens.clone())); Ok(()) @@ -178,6 +186,7 @@ impl TokenStore for CachedTokenStore { fn clear(&self) -> Result<()> { let mut cache = self.cache.lock().map_err(token_error)?; + *cache = None; self.inner.clear()?; *cache = Some(None); Ok(()) @@ -185,8 +194,8 @@ impl TokenStore for CachedTokenStore { fn replace_if_current(&self, expected: &Tokens, tokens: &Tokens) -> Result { let mut cache = self.cache.lock().map_err(token_error)?; + *cache = None; if !self.inner.replace_if_current(expected, tokens)? { - *cache = None; return Ok(false); } *cache = Some(Some(tokens.clone())); @@ -303,8 +312,14 @@ impl EncryptedFsTokenStore { fn clear_file(&self) -> Result<()> { match fs::remove_file(&self.path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(()) => sync_parent(&self.path).map_err(token_error), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match sync_parent(&self.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(token_error(error)), + } + } Err(error) => Err(token_error(error)), } } @@ -350,7 +365,8 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { let mut file = options.open(&temporary)?; file.write_all(bytes)?; file.sync_all()?; - fs::rename(&temporary, path) + fs::rename(&temporary, path)?; + sync_parent(path) })(); if result.is_err() { let _ = fs::remove_file(&temporary); @@ -358,6 +374,33 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { result.map_err(token_error) } +#[cfg(unix)] +fn sync_parent(path: &Path) -> std::io::Result<()> { + #[cfg(all(test, unix))] + if FAIL_NEXT_PARENT_SYNC.with(|next| next.replace(false)) { + return Err(std::io::Error::other( + "injected parent-directory sync failure", + )); + } + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "token file path has no parent", + ) + })?; + fs::File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent(_: &Path) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(all(test, unix))] +fn fail_next_parent_sync() { + FAIL_NEXT_PARENT_SYNC.with(|next| next.set(true)); +} + fn token_error(error: impl std::fmt::Display) -> Error { Error::TokenStore(error.to_string()) } @@ -422,6 +465,9 @@ mod tests { saves: AtomicUsize, clears: AtomicUsize, fail_save: AtomicBool, + fail_save_after_write: AtomicBool, + fail_clear_after_write: AtomicBool, + fail_replace_after_write: AtomicBool, } impl TokenStore for CountingStore { @@ -436,12 +482,18 @@ mod tests { return Err(Error::TokenStore("save failed".into())); } *self.tokens.lock().unwrap() = Some(tokens.clone()); + if self.fail_save_after_write.load(Ordering::Relaxed) { + return Err(Error::TokenStore("save failed after write".into())); + } Ok(()) } fn clear(&self) -> Result<()> { self.clears.fetch_add(1, Ordering::Relaxed); *self.tokens.lock().unwrap() = None; + if self.fail_clear_after_write.load(Ordering::Relaxed) { + return Err(Error::TokenStore("clear failed after write".into())); + } Ok(()) } @@ -451,6 +503,9 @@ mod tests { return Ok(false); } *current = Some(tokens.clone()); + if self.fail_replace_after_write.load(Ordering::Relaxed) { + return Err(Error::TokenStore("replace failed after write".into())); + } Ok(true) } } @@ -563,7 +618,7 @@ mod tests { } #[test] - fn failed_save_keeps_cached_tokens() { + fn failed_save_invalidates_cached_tokens() { let inner = Arc::new(CountingStore { tokens: Mutex::new(Some(tokens("initial"))), ..Default::default() @@ -574,10 +629,42 @@ mod tests { assert!(store.save(&tokens("failed")).is_err()); assert_eq!(store.load().unwrap(), Some(tokens("initial"))); - assert_eq!(inner.loads.load(Ordering::Relaxed), 1); + assert_eq!(inner.loads.load(Ordering::Relaxed), 2); assert_eq!(inner.saves.load(Ordering::Relaxed), 1); } + #[test] + fn backing_errors_after_save_clear_and_replace_invalidate_the_cache() { + let inner = Arc::new(CountingStore { + tokens: Mutex::new(Some(tokens("initial"))), + ..Default::default() + }); + let store = CachedTokenStore::new(Arc::clone(&inner)); + let initial = tokens("initial"); + assert_eq!(store.load().unwrap(), Some(initial.clone())); + + inner.fail_save_after_write.store(true, Ordering::Relaxed); + assert!(store.save(&tokens("saved")).is_err()); + assert_eq!(store.load().unwrap(), Some(tokens("saved"))); + + inner.fail_clear_after_write.store(true, Ordering::Relaxed); + assert!(store.clear().is_err()); + assert_eq!(store.load().unwrap(), None); + + inner.fail_save_after_write.store(false, Ordering::Relaxed); + inner.fail_clear_after_write.store(false, Ordering::Relaxed); + store.save(&initial).unwrap(); + inner + .fail_replace_after_write + .store(true, Ordering::Relaxed); + assert!( + store + .replace_if_current(&initial, &tokens("replaced")) + .is_err() + ); + assert_eq!(store.load().unwrap(), Some(tokens("replaced"))); + } + #[test] fn cached_save_and_clear_commit_backing_and_cache_together() { let entered = Arc::new(Barrier::new(2)); @@ -794,6 +881,35 @@ mod tests { assert_ne!(first_inode, fs::metadata(path).unwrap().ino()); } + #[cfg(unix)] + #[test] + fn encrypted_store_reports_parent_sync_failure_after_replacement() { + let dir = tempfile::tempdir().unwrap(); + let store = encrypted_store(dir.path(), [7; 32], Arc::new(AtomicUsize::new(0))); + store.save(&tokens("before")).unwrap(); + + fail_next_parent_sync(); + assert!(store.save(&tokens("after")).is_err()); + assert_eq!(store.load().unwrap(), Some(tokens("after"))); + } + + #[cfg(unix)] + #[test] + fn encrypted_store_retries_directory_sync_after_unlink_failure() { + let dir = tempfile::tempdir().unwrap(); + let store = encrypted_store(dir.path(), [7; 32], Arc::new(AtomicUsize::new(0))); + store.save(&tokens("before")).unwrap(); + + fail_next_parent_sync(); + assert!(store.clear().is_err()); + assert!(!dir.path().join("tokens.enc").exists()); + + fail_next_parent_sync(); + assert!(store.clear().is_err()); + assert!(!dir.path().join("tokens.enc").exists()); + assert!(store.clear().is_ok()); + } + #[test] fn encrypted_store_reports_corrupt_wrong_key_and_io_distinctly() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cc2fe36..a8f82b3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -2,7 +2,7 @@ ## Prerequisites -- macOS 11+ arm64, Windows 10/11 x64/ARM64 with WebView2 105+, or Ubuntu +- macOS 15+ arm64 (the macOS versions Apple currently supports), Windows 10/11 x64/ARM64 with WebView2 105+, or Ubuntu 22.04 amd64/arm64 with WebKitGTK 4.1 - Rust stable, Node.js 22, and npm - Xcode command-line build tools on macOS diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b0c0b60..cc0926e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -2,9 +2,12 @@ Retune supports these native packages: +Retune follows the macOS releases Apple currently supports; macOS 15 is the +current floor. + | Platform | Supported architecture | Minimum runtime | | --- | --- | --- | -| macOS | Apple Silicon | macOS 11 | +| macOS | Apple Silicon | macOS 15+ (Apple-supported releases) | | Windows 10/11 | x64, ARM64 | WebView2 105 (the installer updates older runtimes) | | Ubuntu 22.04 | amd64, arm64 | Distribution WebKitGTK 4.1 | | Compatible Debian/Ubuntu systems | amd64, arm64 | WebKitGTK 4.1 | diff --git a/docs/SOLID.md b/docs/SOLID.md index 65b3254..7b7019d 100644 --- a/docs/SOLID.md +++ b/docs/SOLID.md @@ -831,8 +831,8 @@ Evidence: frontend test nor lint. - apps/desktop/package.json:6-11 already defines both scripts. - Current lint has seven exhaustive-deps warnings listed in the verification - section. App.tsx:367 can retain a simulated tick interval across a backend - mode change because simulated is missing. + section. App playback state now comes from native player events, so the + frontend has no synthetic timer to drift across backend changes. - A strict TypeScript app probe passes with no production edits, while tsconfig.app.json:2-23 does not enable strict. @@ -849,7 +849,7 @@ Done when: - CI fails for a broken frontend test or hook warning. - Local documented checks and CI commands agree. -- The simulated timer cleans up when simulated mode changes. +- Playback controls and elapsed position come only from the native controller. ### [x] SOLID-018 — Replace test-shaped code with executable behavior proof diff --git a/docs/architecture/lastfm-import-matching.md b/docs/architecture/lastfm-import-matching.md index 8c817cd..edcd120 100644 --- a/docs/architecture/lastfm-import-matching.md +++ b/docs/architecture/lastfm-import-matching.md @@ -50,6 +50,20 @@ shutdown advance the lifecycle generation, wake sleepers, and prevent the old generation from issuing another request. Closing the importer window does not cancel checkpointed work. +When the importer window closes and no session lease, runner, or queued writer is +active, `Service` parks the large in-memory session while keeping its durable +session file and raw-page cache. Status reads use a compact cached view. Any UI +or background operation that needs session data reloads it under the serialized +persistence gate and checks its cache ID, Last.fm username, Spotify account, and +phase before installing it. A missing or changed file returns an error and keeps +the cached owner visible; it is not treated as an absent session. Active work +and pending writes keep the session resident, and the writer tail retries +parking after it finishes. Retune drops the session outside its locks on a +blocking worker, releasing its ownership of that payload on every platform. +The allocator may keep those freed pages for reuse, so this does not guarantee a +lower process footprint or reclaim live WebView/graphics data. Retune does not +call forced allocator APIs or run a periodic garbage collector. + The matching boundary preserves these invariants: - Spotify requests use the shared client, request gate, cooldowns, and @@ -379,9 +393,10 @@ its mode. Completed source rows from other batches participate when they map to the same target. In the `ImportPageView` projection, `fuzzyGroups` remains scoped to the -current batch for source disclosure, while `resolvedCounts` is the -authoritative target-wide result including eligible completed rows, resolved -with the selected count mode. Count projection determines collection shape once +current batch and carries only stable source IDs already present in `rows`, so +source details cross IPC once. `resolvedCounts` is the authoritative target-wide +result including eligible completed rows, resolved with the selected count mode. +Count projection determines collection shape once per batch per request, reusing that result across completed source rows; it does not rebuild the full review queue for every historical row. The review UI collapses contributing rows into one entry per target and review state, with an expanded count flow. Its selection and track-picker actions diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index cb4ad71..3bba928 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -3,13 +3,16 @@ The Tauri shell stores Retune state in the platform application-data directory. All JSON state writes use one shared primitive that creates a unique, same-directory temporary file without truncating an existing writer, syncs it, -and atomically renames it into place. Windows serializes the final replacement -because simultaneous `MoveFileExW` calls can reject one another. Open, write, -sync, and rename failures -leave the previous destination intact and remove only that writer's temporary -file. Secret temporaries are created with mode 0600 on Unix before any bytes are -written; legacy secret permissions are repaired through the open descriptor -before their contents are read. +atomically renames it into place, and on Unix syncs the containing directory +after the replacement. Windows serializes the final replacement and requests +write-through because simultaneous `MoveFileExW` calls can reject one another. +Open, write, sync, and rename failures leave the previous destination intact; +a post-rename directory-sync error reports failure even though the replacement +may already be visible. Owners reconcile the exact disk candidate or prior +value after that error and latch when the result is ambiguous. Secret +temporaries are created with mode 0600 on Unix before any bytes are written; +legacy secret permissions are repaired through the open descriptor before +their contents are read. ## Files @@ -107,8 +110,18 @@ whole token record. Replacing the Web OAuth grant clears the playback credential because the new grant may belong to a different account. It is machine-specific and never belongs in backup/export. -Built-in Spotify playback also maintains an `audio-cache` directory. Cache data -is disposable; library and settings files are not. +The synchronous `TokenStore` API is retained for native startup and other +blocking callers. `SpotifyClient` runs token loads and compare-and-replace +operations on Tokio's blocking pool before publishing the resulting token state +to its request path. + +Built-in Spotify playback maintains an `audio-cache` directory. Cache data is +disposable; library and settings files are not. Local embedded artwork uses a +separate `artwork-cache` directory capped at 512 MiB. It stores raw image bytes +keyed by the local track URI and source-file length/modification time; the data +URL is rebuilt when an image is requested. This cache is disposable and excluded +from backup/export, and its budget does not reduce the audio cache's separate +2 GiB limit. Backup export clones the library, settings, and playlist cache under one owner lock at a time, releasing each before acquiring the next, then fetches Last.fm @@ -127,6 +140,9 @@ all replacement files and the Complete marker are durable. Settings, playlist, and library refreshes are then attempted independently, so one shell-side notification failure cannot suppress the others or reclassify the durable restore as failed. +A failed journal begin is accepted for recovery only when an existing Applying +or Complete record exactly matches the intended transaction; an absent or +unrelated Complete record leaves the original failure in place. `backup.rs` owns the portable envelope, native file dialogs, and multi-owner runtime coordination. `restore.rs` remains the low-level journal and recovery @@ -288,6 +304,14 @@ Startup and the next serialized mutation roll any surviving record forward before accepting a third value. Disk work runs on the blocking pool, and an owned completion publishes all corresponding in-memory snapshots together even if the initiating command is cancelled. +Interactive review changes publish to memory first and mark one coalesced +writer dirty. The writer takes the shared persistence gate before reading the +latest live session and mappings and holds it through the transaction, so a +concurrent durable mutation waits for the disk operation rather than being +overwritten by an older queued snapshot. If the session was invalidated before +that flush, the writer persists the current mappings without recreating the +invalid session. A failed queued save blocks further importer writes until +startup recovery succeeds; any surviving review journal is replayed then. `lastfm-sync.json` stores the Last.fm/Spotify identities, `syncedThrough`, `lastSyncedAt`, one fixed padded download range and cache identity, stable diff --git a/docs/architecture/playback.md b/docs/architecture/playback.md index cd5fb7b..af321f5 100644 --- a/docs/architecture/playback.md +++ b/docs/architecture/playback.md @@ -13,7 +13,9 @@ track URI and playing flag; elapsed-time updates do not refresh the review. The main window sends native elapsed-only changes to a per-player presentation store subscribed by the transport. Queue, origin, metadata, playback flags, and track changes still pass through the app reducer; the controller remains -authoritative. Simulated playback keeps its reducer timer and elapsed value. +authoritative. The frontend does not synthesize playback state: start requests +accept only `spotify:` and `file:` URIs, and fixture or unknown URIs report an +error before they can replace the active queue or origin. When the document is hidden, the presentation store retains native positions without notifying the transport and the title marquee pauses. On visibility restoration, subscribers read the latest position. Native events, queue changes, diff --git a/docs/architecture/spotify.md b/docs/architecture/spotify.md index e1131dc..f54ff71 100644 --- a/docs/architecture/spotify.md +++ b/docs/architecture/spotify.md @@ -34,6 +34,11 @@ public `client` facade keeps transport, request policy, wire models, and endpoin implementations in separate source modules behind that single client and its existing `Transport` seam. +`TokenStore` remains a synchronous boundary because native credential and file +stores may block. The client's asynchronous token loads and compare-and-replace +operations run on Tokio's blocking pool, so Web API requests and playback +commands do not hold the async executor during credential I/O. + PKCE code exchange and refresh share the configured low-level HTTP transport and one form builder/response parser, but remain outside the Web API request gate, cooldown, and request counts. The @@ -115,9 +120,10 @@ quota records coalesce to the latest global deadline on load. Every cooldown surface reads the effective persisted deadline. A successful network-backed search clears only the global quota record; a persistent catalog hit does not. Transient endpoint-family rate limits remain until their own deadline. The desktop exposes a -small `SpotifySyncStatus` snapshot and `spotify-sync-status-changed` event with -connection, running, last-full-sync, effective-next-sync, and cooldown state so -the UI can compose Spotify and Last.fm work without duplicating policy. +small `SpotifySyncStatus` snapshot and `spotify-sync-status-changed` invalidation +event. The event carries no status payload; the UI fetches the snapshot after an +invalidation so status reads use one authoritative path without duplicating +policy. ## Sync and caching diff --git a/docs/performance/tauri-experiments/README.md b/docs/performance/tauri-experiments/README.md index aa8e472..eb4b27f 100644 --- a/docs/performance/tauri-experiments/README.md +++ b/docs/performance/tauri-experiments/README.md @@ -263,6 +263,41 @@ native IPC, memory, account-bound interaction, and steady-refresh benefit remain **UNVERIFIED**. Start the existing performance Vite server and open `/performance/importer.html?window=lastfm-importer&queueFixture=1` to reproduce. +## Last.fm review-payload parking + +Measured 2026-09-20. + +The ignored macOS release test loads a private copy of a real Last.fm session +into test-owned temporary app data, then closes and reloads the review payload +three times. It uses no credentials or network and measures only the isolated +Rust test process, not the installed app, WebView, or graphics allocations. +Reproduce with a private copy of `lastfm-import.json`: + +```sh +rtk proxy env RETUNE_LASTFM_MEMORY_FIXTURE=/path/to/private-copy.json cargo test --quiet --release -p retune-desktop --lib lastfm_import::integration_tests::memory_experiment::real_session_park_reload_memory_experiment -- --ignored --exact --test-threads=1 --nocapture +``` + +The final ownership-only run measured `phys_footprint` with `/usr/bin/footprint`; +the immediate sample was 31–33 ms after payload cleanup completed and the +settled sample was about 250 ms after payload cleanup completed. The test loaded +245,941 scrobbles, with 28,328 remaining; summary counts stayed stable across +each cycle. + +| Stage | Cycle 1 | Cycle 2 | Cycle 3 | +| --- | ---: | ---: | ---: | +| Park / reload time | 42 / 254 ms | 38 / 254 ms | 35 / 246 ms | +| Parked footprint, immediate (bytes) | 74,072,712 | 55,394,952 | 145,130,216 | +| Parked footprint, settled (bytes) | 74,072,712 | 55,394,952 | 145,130,216 | +| Reopened footprint (bytes) | 163,906,280 | 145,097,448 | 150,766,336 | + +The initial resident footprint was 285,770,448 bytes; peak footprint was +286,098,152 bytes. A preceding diagnostic run with the native allocator trim +reported zero bytes released in all three cycles, and its immediate and settled +footprints matched. The ownership-only readings were comparable after removing +that helper, while the third cycle still showed no parked-footprint reduction. +Treat these as observations from one isolated importer process, not a guaranteed +memory saving or a measurement of the full desktop app. + ## Combined validation and current assessment All seven discrete experiments have before/after measurements in this directory. diff --git a/docs/tauri.md b/docs/tauri.md index 7c5cbfe..a3ae586 100644 --- a/docs/tauri.md +++ b/docs/tauri.md @@ -148,7 +148,7 @@ identical. Platform overlays define the supported bundle contract: | Platform | Bundle | Minimum | | --- | --- | --- | -| macOS arm64 | `.app` inside a `ditto`-created ZIP | macOS 11 | +| macOS arm64 | `.app` inside a `ditto`-created ZIP | macOS 15+ (Apple-supported releases) | | Windows x64/ARM64 | NSIS | WebView2 105; downgrade disabled | | Ubuntu amd64/arm64 | Debian package | Ubuntu 22.04 / WebKitGTK 4.1 | diff --git a/packaging/homebrew/retune.rb.template b/packaging/homebrew/retune.rb.template index ae463cb..af31d0b 100644 --- a/packaging/homebrew/retune.rb.template +++ b/packaging/homebrew/retune.rb.template @@ -7,6 +7,8 @@ cask "retune" do desc "Album-first music library for local audio and Spotify" homepage "https://github.com/open-cli-collective/Retune" + depends_on macos: ">= :sequoia" + app "Retune.app" caveats <<~EOS diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 006d975..6a2afd6 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -43,13 +43,7 @@ for (const file of markdown) { } } -const agents = fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"); -for (const file of ["ARCHITECTURE.md", ...markdown.filter((file) => file.startsWith("docs/"))]) { - if (!agents.includes(file)) errors.push(`AGENTS.md does not route to ${file}`); -} - const install = fs.readFileSync(path.join(root, "docs/INSTALL.md"), "utf8"); -const normalizedInstall = install.replace(/\s+/g, " "); for (const value of [ "https://github.com/open-cli-collective/Retune/releases/latest", "Retune--aarch64.zip", @@ -78,16 +72,6 @@ for (const value of [ if (/Retune v\d+\.\d+\.\d+|Retune-\d+\.\d+\.\d+|retune_\d+\.\d+\.\d+/.test(install)) { errors.push("docs/INSTALL.md: release instructions must use latest-release and artifact patterns"); } -for (const value of [ - "Do **not** register `http://127.0.0.1:8898/login`; `/login` is Retune's separate internal built-in-playback callback.", - "Retune uses Authorization Code with PKCE, so it does not need or store the client secret.", - "approve the separate one-time **Authorize Spotify playback** prompt", - "Spotify Development Mode currently requires the application owner to have Premium and limits each app to five authenticated users.", - "Anyone other than the owner must be added to the app allowlist", - "You can import and play local files while signed out.", -]) { - if (!normalizedInstall.includes(value)) errors.push(`docs/INSTALL.md: missing relationship ${value}`); -} if (errors.length) { console.error(errors.join("\n")); diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 847f69a..2ac3e35 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -24,7 +24,6 @@ const buildInstall = read('scripts/build-install.sh') const development = read('docs/DEVELOPMENT.md') const installation = read('docs/INSTALL.md') const tauriArchitecture = read('docs/tauri.md') -const normalized = (text) => text.replace(/\s+/g, ' ') const nativeComposition = read('apps/desktop/src-tauri/src/lib.rs') const frontendState = [ read('apps/desktop/src/App.tsx'), @@ -87,7 +86,7 @@ assert.equal(tauri.bundle.linux.deb.section, 'sound') assert.equal(tauri.bundle.targets, undefined) assert.equal(tauri.bundle.macOS, undefined, 'base config must not force ad-hoc macOS signing') assert.deepEqual(macTauri.bundle.targets, ['app']) -assert.equal(macTauri.bundle.macOS.minimumSystemVersion, '11.0') +assert.equal(macTauri.bundle.macOS.minimumSystemVersion, '15.0') assert.deepEqual(windowsTauri.bundle.targets, ['nsis']) assert.equal(windowsTauri.bundle.windows.allowDowngrades, false) assert.equal(windowsTauri.bundle.windows.minimumWebview2Version, '105.0.0.0') @@ -95,7 +94,11 @@ assert.deepEqual(linuxTauri.bundle.targets, ['deb']) required(vite, "host: '127.0.0.1'") required(vite, 'port: 5173') required(vite, 'strictPort: true') -required(vite, "'windows' ? 'chrome105' : 'safari13'") +required(vite, "process.env.TAURI_ENV_PLATFORM === 'windows'", 'Windows Vite target selector') +required(vite, "'chrome105'", 'Windows Vite target') +required(vite, "process.env.TAURI_ENV_PLATFORM === 'macos'", 'macOS Vite target selector') +required(vite, "'safari18'", 'macOS Vite target') +required(vite, "'safari13'", 'Linux Vite target') required(workflow, 'workflow_dispatch:') required(workflow, 'tags:\n - "v*"') @@ -250,6 +253,7 @@ for (const [label, contents] of [ const sharedCommit = '74d24fcd862d7b9cbe8f6fdda31db6a833e3d706' required(ci, `open-cli-collective/.github/actions/pr-title@${sharedCommit}`) required(ci, 'title: ${{ github.event.pull_request.title }}') +requireImmutableExternalActions('CI workflow', ci) for (const action of ['homebrew-alias', 'winget-submit']) { required(workflow, `open-cli-collective/.github/actions/${action}@${sharedCommit}`) } @@ -326,31 +330,11 @@ required(cask, 'version "__VERSION__"') required(cask, 'sha256 "__SHA256__"') required(cask, 'https://github.com/open-cli-collective/Retune/releases/download') required(cask, 'homepage "https://github.com/open-cli-collective/Retune"') +required(cask, 'depends_on macos: ">= :sequoia"') assert.doesNotMatch(cask, /xattr|postflight/, 'self-signed cask must preserve quarantine for user approval') required(cask, 'self-signed certificate') required(cask, 'not Apple-notarized') -for (const value of [ - 'self-signed certificate', - 'not timestamped, hardened, or Apple-notarized', - 'Windows application payloads and installers are unsigned', - 'protected GitHub Actions environment named `release`', - 'only from `main` and `v*` tags', - 'require maintainer review', -]) required(normalized(development), value, `development release trust contract ${value}`) -for (const value of [ - 'self-signed certificate', - 'not Apple-notarized', - 'application payloads are unsigned', - 'Open Anyway', - 'Unknown Publisher', -]) required(normalized(installation), value, `installation trust contract ${value}`) -for (const value of [ - 'self-signed identity', - 'not timestamped, hardened, or Apple-notarized', - 'outer installer are unsigned', - 'protected GitHub Actions environment named `release`', -]) required(normalized(tauriArchitecture), value, `Tauri distribution contract ${value}`) assert.doesNotMatch(development, /feature branch for packaging validation/i, 'retired feature-branch signing guidance remains') for (const [label, text] of [['development', development], ['installation', installation], ['Tauri architecture', tauriArchitecture], ['Homebrew cask', cask]]) { assert.doesNotMatch(text, /Developer ID|notarized and|Authenticode|Artifact Signing|RFC 3161|stapled ticket|Gatekeeper therefore/i, `${label}: unprovisioned trust claim remains`)