diff --git a/CHANGELOG.md b/CHANGELOG.md index 80646d41..564d6238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Offline — unified local playback, library index join, and favorites sync + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)** + +* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket. +* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution. +* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header. +* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind. +* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically. +* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist. +* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count. + + + ## Changed ### Dependencies — npm and Rust refresh diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f571f8c0..c1416e94 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4266,6 +4266,7 @@ dependencies = [ "psysonic-analysis", "psysonic-audio", "psysonic-core", + "psysonic-library", "reqwest", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47b6a3c3..6635c93a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -6,6 +6,7 @@ resolver = "2" version = "1.48.0-dev" edition = "2021" rust-version = "1.95" +license = "GPL-3.0-or-later" [workspace.dependencies] tempfile = "3" @@ -18,7 +19,7 @@ name = "psysonic" version.workspace = true description = "Psysonic Desktop Music Player" authors = [] -license = "" +license.workspace = true repository = "" default-run = "psysonic" edition.workspace = true diff --git a/src-tauri/about.toml b/src-tauri/about.toml index 719305e2..b8b3438e 100644 --- a/src-tauri/about.toml +++ b/src-tauri/about.toml @@ -21,6 +21,8 @@ accepted = [ "OpenSSL", "BSL-1.0", "CDLA-Permissive-2.0", + "GPL-3.0-or-later", + "bzip2-1.0.6", ] # Skip the build host's own platform-pinning; we want a list across all targets @@ -38,5 +40,4 @@ targets = [ ignore-build-dependencies = false ignore-dev-dependencies = true ignore-transitive-dependencies = false -filter-noassertion = false workarounds = ["ring"] diff --git a/src-tauri/crates/psysonic-analysis/Cargo.toml b/src-tauri/crates/psysonic-analysis/Cargo.toml index bf6cde10..2cf9dfc6 100644 --- a/src-tauri/crates/psysonic-analysis/Cargo.toml +++ b/src-tauri/crates/psysonic-analysis/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-analysis" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 874e7a06..66399c50 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -516,6 +516,145 @@ pub async fn enqueue_track_analysis_from_file( enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await } +/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key, +/// plan enrichment under the library UUID, and skip work when both scopes are complete. +pub async fn enqueue_offline_library_analysis_from_file( + app: &tauri::AppHandle, + server_index_key: &str, + library_server_id: &str, + track_id: &str, + file_path: &std::path::Path, + explicit_priority: Option, +) -> Result<(), String> { + use tokio::io::AsyncReadExt; + + use crate::track_analysis_plan::plan_track_analysis_offline_library; + + let mut file = tokio::fs::File::open(file_path) + .await + .map_err(|e| e.to_string())?; + let mut prefix = vec![0u8; 16384]; + let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?; + prefix.truncate(n); + if prefix.is_empty() { + return Ok(()); + } + let content_hash = analysis_cache::md5_first_16kb(&prefix); + let plan = plan_track_analysis_offline_library( + app, + &[server_index_key, library_server_id], + library_server_id, + track_id, + &content_hash, + ); + if !plan.any() { + crate::app_deprintln!( + "[analysis] offline library seed skip (complete) track_id={} index={} library={}", + track_id, + server_index_key, + library_server_id, + ); + return Ok(()); + } + let bytes = tokio::fs::read(file_path) + .await + .map_err(|e| e.to_string())?; + let format_hint = file_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .filter(|e| !e.is_empty()); + let priority = explicit_priority.unwrap_or_else(|| { + analysis_backfill_resolve_priority(app, server_index_key, track_id, None) + }); + enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue { + app, + cache_server_id: server_index_key, + enrichment_server_id: library_server_id, + track_id, + bytes: &bytes, + format_hint: format_hint.as_deref(), + priority, + plan, + fetch_ms: 0, + }) + .await?; + Ok(()) +} + +struct OfflineLibraryAnalysisEnqueue<'a> { + app: &'a tauri::AppHandle, + cache_server_id: &'a str, + enrichment_server_id: &'a str, + track_id: &'a str, + bytes: &'a [u8], + format_hint: Option<&'a str>, + priority: AnalysisBackfillPriority, + plan: psysonic_core::track_analysis::TrackAnalysisPlan, + fetch_ms: u64, +} + +async fn enqueue_track_analysis_offline_library_with_plan( + args: OfflineLibraryAnalysisEnqueue<'_>, +) -> Result { + if args.bytes.is_empty() || !args.plan.any() { + return Ok(EnqueueTrackAnalysisOutcome::Complete); + } + let content_hash = analysis_cache::md5_first_16kb(args.bytes); + if args.plan.needs_full_cpu_seed() { + crate::app_deprintln!( + "[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}", + args.track_id, + content_hash, + args.plan.need_waveform, + args.plan.need_loudness, + args.plan.enrichment.any() + ); + submit_analysis_cpu_seed( + args.app.clone(), + args.cache_server_id.to_string(), + args.track_id.to_string(), + args.bytes.to_vec(), + args.format_hint.map(str::to_string), + args.priority, + args.fetch_ms, + ) + .await?; + return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed); + } + if args.plan.needs_enrichment_only() { + crate::app_deprintln!( + "[analysis] enrichment-only track_id={} hash={}", + args.track_id, + content_hash + ); + let bpm_started = std::time::Instant::now(); + let outcome = run_track_enrichment_from_bytes( + args.app, + args.enrichment_server_id, + args.track_id, + args.bytes, + analysis_emits_ui_events(args.priority), + ) + .await; + if matches!(outcome, TrackEnrichmentOutcome::Failed) { + if let Some(cache) = args.app.try_state::() { + let key = analysis_cache::TrackKey { + server_id: args.cache_server_id.to_string(), + track_id: args.track_id.to_string(), + md5_16kb: content_hash.clone(), + }; + let _ = cache.touch_track_status(&key, "failed"); + } + return Err("track enrichment failed".to_string()); + } + let bpm_ms = bpm_started.elapsed().as_millis() as u64; + emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms); + return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly); + } + Ok(EnqueueTrackAnalysisOutcome::Complete) +} + /// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`]. pub async fn enqueue_analysis_seed( app: &tauri::AppHandle, diff --git a/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs b/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs index 3761fb84..fcb1660a 100644 --- a/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs +++ b/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs @@ -15,8 +15,21 @@ pub fn plan_track_analysis( track_id: &str, content_hash: &str, ) -> TrackAnalysisPlan { - let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash); - let enrichment = enrichment_plan(app, server_id, track_id, content_hash); + plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash) +} + +/// Offline/library download: waveform cache and enrichment facts may live under the +/// playback index key while library rows use the UUID — try every scope before seeding. +pub fn plan_track_analysis_offline_library( + app: &AppHandle, + cache_server_ids: &[&str], + _enrichment_server_id: &str, + track_id: &str, + content_hash: &str, +) -> TrackAnalysisPlan { + let (need_waveform, need_loudness) = + cache_gaps_multi(app, cache_server_ids, track_id, content_hash); + let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash); TrackAnalysisPlan { need_waveform, need_loudness, @@ -103,6 +116,32 @@ fn cache_gaps( ) } +fn cache_gaps_multi( + app: &AppHandle, + server_ids: &[&str], + track_id: &str, + content_hash: &str, +) -> (bool, bool) { + let mut need_waveform = true; + let mut need_loudness = true; + for &server_id in server_ids { + if server_id.is_empty() { + continue; + } + let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash); + if !nw { + need_waveform = false; + } + if !nl { + need_loudness = false; + } + if !need_waveform && !need_loudness { + break; + } + } + (need_waveform, need_loudness) +} + fn enrichment_plan( app: &AppHandle, server_id: &str, @@ -117,6 +156,45 @@ fn enrichment_plan( .unwrap_or_default() } +fn enrichment_plan_multi( + app: &AppHandle, + server_ids: &[&str], + track_id: &str, + content_hash: &str, +) -> psysonic_core::track_enrichment::TrackEnrichmentPlan { + let mut need_bpm = true; + let mut need_valence = true; + let mut need_arousal = true; + let mut need_moods = true; + for &server_id in server_ids { + if server_id.is_empty() { + continue; + } + let plan = enrichment_plan(app, server_id, track_id, content_hash); + if !plan.need_bpm { + need_bpm = false; + } + if !plan.need_valence { + need_valence = false; + } + if !plan.need_arousal { + need_arousal = false; + } + if !plan.need_moods { + need_moods = false; + } + if !need_bpm && !need_valence && !need_arousal && !need_moods { + break; + } + } + psysonic_core::track_enrichment::TrackEnrichmentPlan { + need_bpm, + need_valence, + need_arousal, + need_moods, + } +} + fn cache_gaps_for_content( cache: Option<&AnalysisCache>, server_id: &str, @@ -194,4 +272,14 @@ mod tests { assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint"); } + #[test] + fn playback_index_cache_row_not_visible_under_library_uuid_only() { + let cache = AnalysisCache::open_in_memory(); + seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc"); + let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc"); + assert!(wf && ld, "library uuid alone should miss playback-scoped cache"); + let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc"); + assert!(!wf2 && !ld2, "playback index key should hit the cached row"); + } + } diff --git a/src-tauri/crates/psysonic-audio/Cargo.toml b/src-tauri/crates/psysonic-audio/Cargo.toml index c9dcfda5..4e18e16c 100644 --- a/src-tauri/crates/psysonic-audio/Cargo.toml +++ b/src-tauri/crates/psysonic-audio/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-audio" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-core/Cargo.toml b/src-tauri/crates/psysonic-core/Cargo.toml index 201f7c64..9b87bd35 100644 --- a/src-tauri/crates/psysonic-core/Cargo.toml +++ b/src-tauri/crates/psysonic-core/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-core" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs b/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs index e23524a7..0159fc39 100644 --- a/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs +++ b/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs @@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool { || id.starts_with("ra-") } +/// Windows reserved device names (case-insensitive) — invalid as path components. +const WINDOWS_RESERVED_NAMES: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + /// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe). +/// Also used for media layout artist/album/title segments from server metadata. pub fn sanitize_path_segment(segment: &str) -> String { const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|']; - let trimmed = segment.trim(); + let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string(); if trimmed.is_empty() { return "_".to_string(); } - trimmed + let cleaned: String = trimmed .chars() - .map(|c| if FORBIDDEN.contains(&c) { '_' } else { c }) - .collect() + .map(|c| { + if c.is_control() || FORBIDDEN.contains(&c) { + '_' + } else { + c + } + }) + .collect(); + if cleaned.is_empty() || cleaned == "." || cleaned == ".." { + return "_".to_string(); + } + let upper = cleaned.to_ascii_uppercase(); + if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) { + return format!("_{cleaned}"); + } + cleaned } /// Relative path under `{root}/{server_segment}/` — change format here only. @@ -256,6 +277,13 @@ mod tests { base } + #[test] + fn sanitize_rejects_dot_dot_and_reserved_names() { + assert_eq!(sanitize_path_segment(".."), "_"); + assert_eq!(sanitize_path_segment("CON"), "_CON"); + assert_eq!(sanitize_path_segment(" trailing. "), "trailing"); + } + #[test] fn segment_disk_usage_counts_canonical_only() { let server = test_server_dir("usage"); diff --git a/src-tauri/crates/psysonic-core/src/lib.rs b/src-tauri/crates/psysonic-core/src/lib.rs index c28dd60c..5ad81722 100644 --- a/src-tauri/crates/psysonic-core/src/lib.rs +++ b/src-tauri/crates/psysonic-core/src/lib.rs @@ -5,6 +5,7 @@ //! between `psysonic-audio`, `psysonic-analysis`, and other domain crates. pub mod cover_cache_layout; +pub mod media_layout; pub mod logging; pub mod ports; pub mod track_analysis; diff --git a/src-tauri/crates/psysonic-core/src/media_layout.rs b/src-tauri/crates/psysonic-core/src/media_layout.rs new file mode 100644 index 00000000..24880ce6 --- /dev/null +++ b/src-tauri/crates/psysonic-core/src/media_layout.rs @@ -0,0 +1,364 @@ +//! Local playback disk layout — artist/album/track paths from library-index fields. +//! +//! Mirrors the contract in `implementation-spec.md` (local playback unification). +//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL +//! index key; artist/album/filename segments are derived from track metadata only. + +use std::path::{Component, Path, PathBuf}; + +use crate::cover_cache_layout::sanitize_path_segment; + +/// Max length for a single path component after sanitization (Windows budget). +pub const MAX_SEGMENT_LEN: usize = 120; + +/// Inputs required to build hierarchical media paths (library index row projection). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackPathInput { + pub artist: Option, + pub album_artist: Option, + pub album: String, + pub title: String, + pub track_number: Option, + pub disc_number: Option, + pub suffix: Option, + /// When set, used to detect compilation albums from `raw_json` (OpenSubsonic). + pub raw_json: Option, +} + +/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocalTier { + Ephemeral, + Library, + /// Auto-synced starred favorites — separate from user-pinned `library/`. + Favorites, +} + +impl LocalTier { + pub fn subdir(self) -> &'static str { + match self { + Self::Ephemeral => "cache", + Self::Library => "library", + Self::Favorites => "favorites", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "ephemeral" | "cache" => Some(Self::Ephemeral), + "library" => Some(Self::Library), + "favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites), + _ => None, + } + } +} + +/// Stable fingerprint for invalidation when library metadata changes (§8 spec). +pub fn layout_fingerprint(input: &TrackPathInput) -> String { + let artist_seg = artist_folder_segment(input); + let album_seg = album_folder_segment(&input.album); + let stem = track_filename_stem(input); + let suffix = input + .suffix + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(""); + let track_n = input.track_number.unwrap_or(0); + let disc_n = input.disc_number.unwrap_or(0); + format!( + "artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}", + input + .album_artist + .as_deref() + .map(str::trim) + .unwrap_or(""), + input.title.trim(), + ) +} + +/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`. +pub fn relative_path_for_track( + server_index_key: &str, + input: &TrackPathInput, + suffix: &str, +) -> PathBuf { + let server_segment = sanitize_path_segment(server_index_key); + let artist = artist_folder_segment(input); + let album = album_folder_segment(&input.album); + let stem = track_filename_stem(input); + let ext = suffix.trim().trim_start_matches('.'); + let filename = if ext.is_empty() { + sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN) + } else { + format!( + "{}.{}", + sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN), + sanitize_path_segment(ext) + ) + }; + PathBuf::from(server_segment) + .join(artist) + .join(album) + .join(filename) +} + +/// Absolute file path: `{media_root}/{tier}/…relative_path…`. +pub fn absolute_track_path( + media_root: &Path, + tier: LocalTier, + server_index_key: &str, + input: &TrackPathInput, + suffix: &str, +) -> PathBuf { + media_root + .join(tier.subdir()) + .join(relative_path_for_track(server_index_key, input, suffix)) +} + +/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`. +pub fn ensure_track_path_within_tier( + media_root: &Path, + tier: LocalTier, + absolute: &Path, +) -> Result<(), String> { + let tier_root = media_root.join(tier.subdir()); + let Ok(rel) = absolute.strip_prefix(&tier_root) else { + return Err(format!( + "path `{}` escapes tier root `{}`", + absolute.display(), + tier_root.display() + )); + }; + for comp in rel.components() { + if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) { + return Err(format!( + "path `{}` contains forbidden component `{comp:?}`", + absolute.display() + )); + } + } + Ok(()) +} + +fn artist_folder_segment(input: &TrackPathInput) -> String { + let artist = input.artist.as_deref().map(str::trim).unwrap_or(""); + let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or(""); + let chosen = if artist.is_empty() || track_is_compilation(input) { + if !album_artist.is_empty() { + album_artist + } else { + "Various Artists" + } + } else { + artist + }; + sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN) +} + +fn album_folder_segment(album: &str) -> String { + let trimmed = album.trim(); + let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed }; + sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN) +} + +fn track_filename_stem(input: &TrackPathInput) -> String { + let title = input.title.trim(); + let title = if title.is_empty() { "Unknown Title" } else { title }; + let track_n = input.track_number.unwrap_or(0).max(0) as u32; + let disc_n = input.disc_number.unwrap_or(1).max(0) as u32; + if disc_n > 1 { + format!("{disc_n:02}-{track_n:02} - {title}") + } else { + format!("{track_n:02} - {title}") + } +} + +fn track_is_compilation(input: &TrackPathInput) -> bool { + if various_artists_label(input.artist.as_deref().unwrap_or("")) { + return true; + } + let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else { + return false; + }; + raw_json_marks_compilation(raw) +} + +/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`. +fn raw_json_marks_compilation(raw: &str) -> bool { + let lower = raw.to_ascii_lowercase(); + lower.contains("\"iscompilation\":true") + || lower.contains("\"iscompilation\": true") + || lower.contains("\"compilation\":true") + || lower.contains("\"compilation\": true") + || lower.contains("\"compilation\":1") + || lower.contains("\"releaseTypes\"") && lower.contains("compilation") +} + +fn various_artists_label(s: &str) -> bool { + let lower = s.trim().to_ascii_lowercase(); + lower.contains("various artists") +} + +fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String { + let sanitized = sanitize_path_segment(segment); + // Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`. + if sanitized.chars().count() <= max_len { + return sanitized; + } + let hash = short_hash(segment); + let keep = max_len.saturating_sub(1 + hash.len()); + let mut out = sanitized.chars().take(keep).collect::(); + out.push('_'); + out.push_str(&hash); + out +} + +/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units). +fn short_hash(s: &str) -> String { + let mut h: u32 = 0; + for unit in s.encode_utf16() { + h = h.wrapping_mul(31).wrapping_add(unit as u32); + } + format!("{:08x}", h) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_input() -> TrackPathInput { + TrackPathInput { + artist: Some("Radiohead".to_string()), + album_artist: None, + album: "OK Computer".to_string(), + title: "Paranoid Android".to_string(), + track_number: Some(6), + disc_number: Some(1), + suffix: Some("mp3".to_string()), + raw_json: None, + } + } + + #[test] + fn relative_path_uses_library_segments() { + let rel = relative_path_for_track("host:4533", &sample_input(), "mp3"); + assert_eq!( + rel, + PathBuf::from("host_4533") + .join("Radiohead") + .join("OK Computer") + .join("06 - Paranoid Android.mp3") + ); + } + + #[test] + fn multi_disc_adds_disc_prefix() { + let mut input = sample_input(); + input.disc_number = Some(2); + let rel = relative_path_for_track("srv", &input, "flac"); + assert!(rel + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac"))); + } + + #[test] + fn compilation_uses_album_artist_folder() { + let input = TrackPathInput { + artist: Some("Various Artists".to_string()), + album_artist: Some("Original Soundtrack".to_string()), + album: "Film Score".to_string(), + title: "Main Theme".to_string(), + track_number: Some(1), + disc_number: Some(1), + suffix: Some("mp3".to_string()), + raw_json: None, + }; + let rel = relative_path_for_track("srv", &input, "mp3"); + assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack")); + } + + #[test] + fn empty_artist_falls_back_to_various_artists() { + let input = TrackPathInput { + artist: None, + album_artist: None, + album: "Comp".to_string(), + title: "Song".to_string(), + track_number: Some(1), + disc_number: Some(1), + suffix: Some("mp3".to_string()), + raw_json: None, + }; + let rel = relative_path_for_track("srv", &input, "mp3"); + assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists")); + } + + #[test] + fn layout_fingerprint_is_stable() { + let a = layout_fingerprint(&sample_input()); + let b = layout_fingerprint(&sample_input()); + assert_eq!(a, b); + assert!(a.contains("Radiohead")); + assert!(a.contains("OK Computer")); + } + + #[test] + fn tier_subdirs_are_fixed() { + assert_eq!(LocalTier::Ephemeral.subdir(), "cache"); + assert_eq!(LocalTier::Library.subdir(), "library"); + assert_eq!(LocalTier::Favorites.subdir(), "favorites"); + assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral)); + assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library)); + assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites)); + } + + #[test] + fn absolute_path_includes_tier() { + let root = Path::new("/media"); + let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3"); + assert!(path.starts_with(root.join("library"))); + } + + #[test] + fn dot_dot_metadata_does_not_escape_tier_root() { + let input = TrackPathInput { + artist: Some("..".to_string()), + album_artist: None, + album: "..".to_string(), + title: "Song".to_string(), + track_number: Some(1), + disc_number: Some(1), + suffix: Some("mp3".to_string()), + raw_json: None, + }; + let root = Path::new("/media"); + let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3"); + assert!(path.starts_with(root.join("library"))); + ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap(); + } + + #[test] + fn short_hash_matches_ts_imul31_utf16() { + // "Radiohead" — same as mediaLayout.test parity anchor. + assert_eq!(short_hash("Radiohead"), "3da68c3b"); + } + + #[test] + fn sanitize_and_truncate_uses_code_point_threshold() { + let cyrillic_a = '\u{0430}'; + let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect(); + assert!(hundred.len() > MAX_SEGMENT_LEN); + assert_eq!(hundred.chars().count(), 100); + assert_eq!( + sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN), + hundred + ); + + let long: String = std::iter::repeat_n(cyrillic_a, 130).collect(); + let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN); + assert!(truncated.ends_with("_eef20600")); + assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN); + } +} diff --git a/src-tauri/crates/psysonic-integration/Cargo.toml b/src-tauri/crates/psysonic-integration/Cargo.toml index 62553ecb..8f866ceb 100644 --- a/src-tauri/crates/psysonic-integration/Cargo.toml +++ b/src-tauri/crates/psysonic-integration/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-integration" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-library/Cargo.toml b/src-tauri/crates/psysonic-library/Cargo.toml index a51ff478..f07adb8a 100644 --- a/src-tauri/crates/psysonic-library/Cargo.toml +++ b/src-tauri/crates/psysonic-library/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-library" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 6b51714b..e466a1b1 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -396,6 +396,40 @@ pub async fn library_get_tracks_by_album( Ok(rows.iter().map(LibraryTrackDto::from_row).collect()) } +/// Upsert Subsonic API song payloads into the library index so pin/download can +/// build `media/library/…` paths before a full sync has ingested the rows. +#[tauri::command] +pub fn library_upsert_songs_from_api( + runtime: State<'_, LibraryRuntime>, + server_id: String, + songs: Vec, +) -> Result { + use crate::sync::subsonic_song_to_track_row; + use psysonic_integration::subsonic::Song; + + if songs.is_empty() { + return Ok(0); + } + let synced_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_secs() as i64; + let repo = TrackRepository::new(&runtime.store); + let mut rows = Vec::with_capacity(songs.len()); + for raw in songs { + let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?; + rows.push(subsonic_song_to_track_row( + &server_id, + &song, + &raw, + synced_at, + None, + )); + } + repo.upsert_batch(&rows)?; + Ok(rows.len() as u32) +} + #[tauri::command] pub async fn library_get_artifact( runtime: State<'_, LibraryRuntime>, diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs index 283877c2..3fdf7eb4 100644 --- a/src-tauri/crates/psysonic-library/src/repos/track.rs +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -228,6 +228,18 @@ impl<'a> TrackRepository<'a> { }) } + /// All live rows for a Subsonic track id (any server). Used when legacy offline + /// folders name the server by URL index key rather than profile UUID. + pub fn find_live_by_id(&self, track_id: &str) -> Result, String> { + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?; + let rows = stmt + .query_map(params![track_id], row_to_track_row)? + .collect::, _>>()?; + Ok(rows) + }) + } + /// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs /// preserve their order in the result; unknown / deleted refs /// are silently dropped (frontend reads `tracks.length` against @@ -292,6 +304,26 @@ impl<'a> TrackRepository<'a> { }) } + /// Legacy offline rows keyed by library `server_id` (index key scope). + pub fn list_offline_local_paths( + &self, + server_id: &str, + ) -> Result)>, String> { + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1", + )?; + let rows = stmt.query_map(params![server_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + rows.collect::>>() + }) + } + /// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS. /// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`]. pub fn list_analysis_hash_bpm_ids_after( @@ -583,6 +615,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \ FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0"; +const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \ + album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \ + server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \ + content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \ + FROM track WHERE id = ?1 AND deleted = 0"; + const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \ album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \ diff --git a/src-tauri/crates/psysonic-syncfs/Cargo.toml b/src-tauri/crates/psysonic-syncfs/Cargo.toml index 3cab3148..ff990b89 100644 --- a/src-tauri/crates/psysonic-syncfs/Cargo.toml +++ b/src-tauri/crates/psysonic-syncfs/Cargo.toml @@ -3,10 +3,12 @@ name = "psysonic-syncfs" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] psysonic-core = { path = "../psysonic-core" } +psysonic-library = { path = "../psysonic-library" } psysonic-analysis = { path = "../psysonic-analysis" } psysonic-audio = { path = "../psysonic-audio" } diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs b/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs index bf081aa4..d5186ecd 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; /// Recursively sums the size of all files under `root`. /// Missing roots, unreadable directories, and unreadable files are silently skipped. @@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 { total } +/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list. +pub fn collect_regular_files_under(root: &Path) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let rd = match std::fs::read_dir(&dir) { + Ok(r) => r, + Err(_) => continue, + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.is_file() { + files.push(path); + } + } + } + files +} + +fn normalize_path_for_prefix(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path. +pub fn local_tier_boundary_from_path(path: &Path) -> Option { + let mut current = path.parent()?; + loop { + match current.file_name().and_then(|s| s.to_str()) { + Some("cache") | Some("library") | Some("favorites") => { + return Some(current.to_path_buf()); + } + _ => current = current.parent()?, + } + } +} + /// Walks upward from `start_dir`, removing each empty directory using `remove_dir` /// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the /// boundary is reached, or removal fails. @@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 { /// `boundary` is never removed and is treated as a hard stop. If `start_dir` is /// not under `boundary`, the function is a no-op. pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) { + let boundary_norm = normalize_path_for_prefix(boundary); let mut current = Some(start_dir.to_path_buf()); while let Some(dir) = current { - if dir == boundary || !dir.starts_with(boundary) { + let dir_norm = normalize_path_for_prefix(&dir); + if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) { break; } match std::fs::read_dir(&dir) { @@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) { } } +/// Post-order sweep: removes empty child directories under `root` (never `root` itself). +pub fn prune_empty_subdirs_under(root: &Path) { + if !root.is_dir() { + return; + } + let children: Vec = std::fs::read_dir(root) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + for child in children { + prune_empty_subdirs_under(&child); + let is_empty = std::fs::read_dir(&child) + .map(|mut rd| rd.next().is_none()) + .unwrap_or(false); + if is_empty { + let _ = std::fs::remove_dir(&child); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -87,6 +153,17 @@ mod tests { assert!(path.exists(), "boundary dir must never be removed"); } + #[test] + fn collect_regular_files_under_lists_nested_files() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.mp3"), b"x").unwrap(); + let sub = dir.path().join("Artist/Album"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("b.flac"), b"yy").unwrap(); + let files = collect_regular_files_under(dir.path()); + assert_eq!(files.len(), 2); + } + #[test] fn prune_empty_dirs_up_to_stops_at_non_empty_parent() { let root = tempfile::tempdir().unwrap(); @@ -98,4 +175,25 @@ mod tests { assert!(!child.exists(), "empty leaf should be pruned"); assert!(parent.exists(), "non-empty parent must stay"); } + + #[test] + fn local_tier_boundary_from_path_finds_cache_root() { + let root = tempfile::tempdir().unwrap(); + let track = root + .path() + .join("vol/media/cache/my.server/Artist/Album/track.flac"); + std::fs::create_dir_all(track.parent().unwrap()).unwrap(); + let boundary = local_tier_boundary_from_path(&track).unwrap(); + assert_eq!(boundary, root.path().join("vol/media/cache")); + } + + #[test] + fn prune_empty_subdirs_under_removes_nested_empty_tree() { + let root = tempfile::tempdir().unwrap(); + let cache = root.path().join("cache").join("srv").join("Artist").join("Album"); + std::fs::create_dir_all(&cache).unwrap(); + prune_empty_subdirs_under(&root.path().join("cache")); + assert!(!cache.exists()); + assert!(root.path().join("cache").exists(), "tier root preserved"); + } } diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/local.rs b/src-tauri/crates/psysonic-syncfs/src/cache/local.rs new file mode 100644 index 00000000..ed4a3de5 --- /dev/null +++ b/src-tauri/crates/psysonic-syncfs/src/cache/local.rs @@ -0,0 +1,1422 @@ +//! Unified local playback download primitive (LP-1). +//! +//! Builds hierarchical paths from the library index row and downloads bytes +//! under `{media}/{cache|library}/…`. Legacy `download_track_hot_cache` / +//! `download_track_offline` remain until LP-2/3 switch call sites. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; + +use psysonic_analysis::analysis_runtime::{ + enqueue_offline_library_analysis_from_file, enqueue_track_analysis, AnalysisBackfillPriority, +}; +use psysonic_audio as audio; +use psysonic_core::cover_cache_layout::sanitize_path_segment; +use psysonic_core::media_layout::{ + absolute_track_path, ensure_track_path_within_tier, layout_fingerprint, LocalTier, + TrackPathInput, +}; +use psysonic_library::repos::TrackRow; +use psysonic_library::{repos::TrackRepository, LibraryRuntime}; +use tauri::{AppHandle, Manager, State}; + +use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::{offline_cancel_flags, DownloadSemaphore}; + +use super::offline::enqueue_analysis_seed_from_file; + +/// Resolved media root `M` — user `mediaDir` or `{app_data}/media/`. +pub fn resolve_media_dir(custom_media_dir: Option<&str>, app: &AppHandle) -> Result { + if let Some(cd) = custom_media_dir.filter(|s| !s.is_empty()) { + let base = std::path::PathBuf::from(cd); + if !base.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + Ok(base) + } else { + Ok(app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("media")) + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalTrackDownloadResult { + pub path: String, + pub size: u64, + pub layout_fingerprint: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryTrackProbeResult { + pub path: String, + pub size: u64, + pub layout_fingerprint: String, + pub exists: bool, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryTierDiskHit { + pub track_id: String, + pub path: String, + pub size: u64, + pub layout_fingerprint: String, + pub suffix: String, +} + +struct ResolvedLibraryTrackPath { + file_path: PathBuf, + path_str: String, + layout_fingerprint: String, +} + +fn resolve_library_track_path( + track_id: &str, + server_index_key: &str, + library_server_id: &str, + suffix: &str, + media_dir: Option<&str>, + app: &AppHandle, + runtime: &LibraryRuntime, +) -> Result { + resolve_track_path_for_tier(ResolveTrackPathForTier { + tier: LocalTier::Library, + track_id, + server_index_key, + library_server_id, + suffix, + media_dir, + app, + runtime, + }) +} + +struct ResolveTrackPathForTier<'a> { + tier: LocalTier, + track_id: &'a str, + server_index_key: &'a str, + library_server_id: &'a str, + suffix: &'a str, + media_dir: Option<&'a str>, + app: &'a AppHandle, + runtime: &'a LibraryRuntime, +} + +fn resolve_track_path_for_tier( + args: ResolveTrackPathForTier<'_>, +) -> Result { + let repo = TrackRepository::new(&args.runtime.store); + let Some(row) = repo.find_one(args.library_server_id, args.track_id)? else { + return Err("LIBRARY_TRACK_NOT_FOUND".to_string()); + }; + let path_input = track_row_to_path_input(&row); + let fingerprint = layout_fingerprint(&path_input); + let media_root = resolve_media_dir(args.media_dir, args.app)?; + let file_path = absolute_track_path( + &media_root, + args.tier, + args.server_index_key, + &path_input, + args.suffix, + ); + Ok(ResolvedLibraryTrackPath { + path_str: file_path.to_string_lossy().to_string(), + file_path, + layout_fingerprint: fingerprint, + }) +} + +fn normalize_path_key(path: &Path) -> String { + path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string() +} + +/// Per-track download mutex — serializes concurrent `download_track_local` / +/// `promote_stream_cache_to_local` for the same `(tier, server, track)` so two +/// callers do not stream into the same `.part` file (M5). +fn track_download_locks( +) -> &'static tokio::sync::Mutex>>> { + static LOCKS: OnceLock>>>> = + OnceLock::new(); + LOCKS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) +} + +async fn acquire_per_track_download_lock(key: &str) -> tokio::sync::OwnedMutexGuard<()> { + let lock_arc = { + let mut map = track_download_locks().lock().await; + map.entry(key.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + lock_arc.lock_owned().await +} + +fn per_track_download_lock_key(tier: LocalTier, server_index_key: &str, track_id: &str) -> String { + format!("{}:{}:{}", tier.subdir(), server_index_key, track_id) +} + +/// Part file beside the final track; keyed by sanitized `track_id` instead of +/// replacing the media extension so concurrent different-suffix attempts do not +/// share one `{suffix}.part` on the same stem. +fn unique_part_path(file_path: &Path, suffix: &str, track_id: &str) -> PathBuf { + let parent = file_path.parent().unwrap_or_else(|| Path::new(".")); + let safe_id = sanitize_path_segment(track_id); + parent.join(format!("{safe_id}.{suffix}.part")) +} + +fn track_row_to_path_input(row: &psysonic_library::repos::TrackRow) -> TrackPathInput { + TrackPathInput { + artist: row.artist.clone(), + album_artist: row.album_artist.clone(), + album: row.album.clone(), + title: row.title.clone(), + track_number: row.track_number, + disc_number: row.disc_number, + suffix: row.suffix.clone(), + raw_json: Some(row.raw_json.clone()), + } +} + +async fn local_track_hit_if_exists( + file_path: &Path, + path_str: &str, + fingerprint: &str, + app: &AppHandle, + server_index_key: &str, + library_server_id: &str, + track_id: &str, +) -> Result, String> { + if !file_path.is_file() { + return Ok(None); + } + let size = tokio::fs::metadata(file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + let app_seed = app.clone(); + let tid = track_id.to_string(); + let index_key = server_index_key.to_string(); + let library_id = library_server_id.to_string(); + let fp = file_path.to_path_buf(); + tokio::spawn(async move { + let _ = enqueue_offline_library_analysis_from_file( + &app_seed, + &index_key, + &library_id, + &tid, + &fp, + None, + ) + .await; + }); + Ok(Some(LocalTrackDownloadResult { + path: path_str.to_string(), + size, + layout_fingerprint: fingerprint.to_string(), + })) +} + +/// Downloads a track into the unified media layout. Library/Favorites tiers require +/// a library index row (cold miss → `LIBRARY_TRACK_NOT_FOUND`); Ephemeral returns +/// `TRACK_NOT_INDEXED` when the row is missing. Disk scope uses `server_index_key`; +/// SQL lookup uses `library_server_id`. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn download_track_local( + tier: String, + track_id: String, + server_index_key: String, + library_server_id: String, + url: String, + suffix: String, + media_dir: Option, + download_id: Option, + runtime: State<'_, LibraryRuntime>, + dl_sem: State<'_, DownloadSemaphore>, + app: AppHandle, +) -> Result { + let local_tier = LocalTier::parse(&tier).ok_or_else(|| format!("unknown local tier: `{tier}`"))?; + + let resolved = if local_tier == LocalTier::Library || local_tier == LocalTier::Favorites { + resolve_track_path_for_tier(ResolveTrackPathForTier { + tier: local_tier, + track_id: &track_id, + server_index_key: &server_index_key, + library_server_id: &library_server_id, + suffix: &suffix, + media_dir: media_dir.as_deref(), + app: &app, + runtime: &runtime, + })? + } else { + let repo = TrackRepository::new(&runtime.store); + let Some(row) = repo.find_one(&library_server_id, &track_id)? else { + return Err("TRACK_NOT_INDEXED".to_string()); + }; + let path_input = track_row_to_path_input(&row); + let fingerprint = layout_fingerprint(&path_input); + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let file_path = absolute_track_path( + &media_root, + local_tier, + &server_index_key, + &path_input, + &suffix, + ); + ResolvedLibraryTrackPath { + path_str: file_path.to_string_lossy().to_string(), + file_path, + layout_fingerprint: fingerprint, + } + }; + let ResolvedLibraryTrackPath { + file_path, + path_str, + layout_fingerprint: fingerprint, + } = resolved; + + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + ensure_track_path_within_tier(&media_root, local_tier, &file_path) + .map_err(|e| e.to_string())?; + + if let Some(hit) = local_track_hit_if_exists( + &file_path, + &path_str, + &fingerprint, + &app, + &server_index_key, + &library_server_id, + &track_id, + ) + .await? + { + return Ok(hit); + } + + let _track_guard = acquire_per_track_download_lock(&per_track_download_lock_key( + local_tier, + &server_index_key, + &track_id, + )) + .await; + + if let Some(hit) = local_track_hit_if_exists( + &file_path, + &path_str, + &fingerprint, + &app, + &server_index_key, + &library_server_id, + &track_id, + ) + .await? + { + return Ok(hit); + } + + let cancel_flag: Option> = download_id.as_deref().and_then(|id| { + offline_cancel_flags().lock().ok().map(|mut flags| { + flags + .entry(id.to_string()) + .or_insert_with(|| Arc::new(AtomicBool::new(false))) + .clone() + }) + }); + + let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?; + + if cancel_flag.as_ref().is_some_and(|f| f.load(Ordering::Relaxed)) { + return Err("CANCELLED".to_string()); + } + + if let Some(hit) = local_track_hit_if_exists( + &file_path, + &path_str, + &fingerprint, + &app, + &server_index_key, + &library_server_id, + &track_id, + ) + .await? + { + return Ok(hit); + } + + if let Some(parent) = file_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + + let client = subsonic_http_client(std::time::Duration::from_secs(120))?; + let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + let part_path = unique_part_path(&file_path, &suffix, &track_id); + finalize_streamed_download( + response, + &file_path, + &part_path, + cancel_flag.as_deref(), + ) + .await?; + + enqueue_offline_library_analysis_from_file( + &app, + &server_index_key, + &library_server_id, + &track_id, + &file_path, + None, + ) + .await?; + + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + + Ok(LocalTrackDownloadResult { + path: path_str, + size, + layout_fingerprint: fingerprint, + }) +} + +/// Scan library-tier bytes on disk and match them to known candidates only +/// (`track_offline.local_path` + canonical paths for `candidate_track_ids`). +#[tauri::command] +pub async fn discover_library_tier_on_disk( + server_index_key: String, + library_server_id: String, + candidate_track_ids: Vec, + media_dir: Option, + runtime: State<'_, LibraryRuntime>, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let segment = sanitize_path_segment(&server_index_key); + let tier_root = media_root + .join(LocalTier::Library.subdir()) + .join(&segment); + let disk_files: HashSet = if tier_root.is_dir() { + super::fs_utils::collect_regular_files_under(&tier_root) + .into_iter() + .map(|p| normalize_path_key(&p)) + .collect() + } else { + HashSet::new() + }; + if disk_files.is_empty() { + return Ok(Vec::new()); + } + + let repo = TrackRepository::new(&runtime.store); + let mut hits: Vec = Vec::new(); + let mut seen_tracks: HashSet = HashSet::new(); + + let offline_rows = repo.list_offline_local_paths(&library_server_id)?; + + for (track_id, local_path, suffix_opt) in offline_rows { + if seen_tracks.contains(&track_id) { + continue; + } + let path = PathBuf::from(&local_path); + let key = normalize_path_key(&path); + if !disk_files.contains(&key) && !path.is_file() { + continue; + } + let Some(row) = repo.find_one(&library_server_id, &track_id)? else { + continue; + }; + let suffix = suffix_opt + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .or_else(|| row.suffix.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .unwrap_or("mp3"); + let path_input = track_row_to_path_input(&row); + let fingerprint = layout_fingerprint(&path_input); + let size = tokio::fs::metadata(&path).await.map(|m| m.len()).unwrap_or(0); + seen_tracks.insert(track_id.clone()); + hits.push(LibraryTierDiskHit { + track_id, + path: local_path, + size, + layout_fingerprint: fingerprint, + suffix: suffix.to_string(), + }); + } + + for track_id in candidate_track_ids { + if seen_tracks.contains(&track_id) { + continue; + } + let Some(row) = repo.find_one(&library_server_id, &track_id)? else { + continue; + }; + let suffix = row + .suffix + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("mp3"); + let resolved = resolve_library_track_path( + &track_id, + &server_index_key, + &library_server_id, + suffix, + media_dir.as_deref(), + &app, + &runtime, + )?; + let canonical_key = normalize_path_key(&resolved.file_path); + if !disk_files.contains(&canonical_key) && !resolved.file_path.is_file() { + continue; + } + let size = tokio::fs::metadata(&resolved.file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + seen_tracks.insert(track_id.clone()); + hits.push(LibraryTierDiskHit { + track_id, + path: resolved.path_str, + size, + layout_fingerprint: resolved.layout_fingerprint, + suffix: suffix.to_string(), + }); + } + + Ok(hits) +} + +/// Resolve the canonical `library/` path for a track and report on-disk presence only +/// (no download, no analysis seed). +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn probe_library_track_local( + track_id: String, + server_index_key: String, + library_server_id: String, + suffix: String, + media_dir: Option, + runtime: State<'_, LibraryRuntime>, + app: AppHandle, +) -> Result { + let resolved = resolve_library_track_path( + &track_id, + &server_index_key, + &library_server_id, + &suffix, + media_dir.as_deref(), + &app, + &runtime, + )?; + let exists = resolved.file_path.is_file(); + let size = if exists { + tokio::fs::metadata(&resolved.file_path) + .await + .map(|m| m.len()) + .unwrap_or(0) + } else { + 0 + }; + Ok(LibraryTrackProbeResult { + path: resolved.path_str, + size, + layout_fingerprint: resolved.layout_fingerprint, + exists, + }) +} + +async fn prune_orphan_files_under_root(root: &Path, keep_paths: &[String]) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + let keep: HashSet = keep_paths + .iter() + .map(|p| normalize_path_key(Path::new(p))) + .collect(); + let mut removed = Vec::new(); + for file in super::fs_utils::collect_regular_files_under(root) { + if keep.contains(&normalize_path_key(&file)) { + continue; + } + if tokio::fs::remove_file(&file).await.is_err() { + continue; + } + removed.push(file.to_string_lossy().to_string()); + if let Some(parent) = file.parent() { + super::fs_utils::prune_empty_dirs_up_to(parent, root); + } + } + super::fs_utils::prune_empty_subdirs_under(root); + removed +} + +/// Remove library-tier files under `{server_index_key}` that are not listed in `keep_paths`. +#[tauri::command] +pub async fn prune_orphan_library_tier_files( + server_index_key: String, + keep_paths: Vec, + media_dir: Option, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let segment = sanitize_path_segment(&server_index_key); + let root = media_root.join(LocalTier::Library.subdir()).join(segment); + Ok(prune_orphan_files_under_root(&root, &keep_paths).await) +} + +struct OrphanCacheFile { + path: PathBuf, + size: u64, + modified: std::time::SystemTime, +} + +/// Delete cache files not in `keep_paths`, oldest mtime first, until total size ≤ `max_bytes`. +async fn evict_orphan_files_under_root_to_fit( + root: &Path, + keep_paths: &[String], + max_bytes: u64, +) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + let mut total = super::fs_utils::dir_size_recursive(root); + if total <= max_bytes { + return Vec::new(); + } + + let keep: HashSet = keep_paths + .iter() + .map(|p| normalize_path_key(Path::new(p))) + .collect(); + + let mut orphans: Vec = Vec::new(); + for file in super::fs_utils::collect_regular_files_under(root) { + if keep.contains(&normalize_path_key(&file)) { + continue; + } + let meta = match std::fs::metadata(&file) { + Ok(m) => m, + Err(_) => continue, + }; + orphans.push(OrphanCacheFile { + path: file, + size: meta.len(), + modified: meta.modified().unwrap_or(std::time::UNIX_EPOCH), + }); + } + orphans.sort_by_key(|f| f.modified); + + let mut removed = Vec::new(); + for orphan in orphans { + if total <= max_bytes { + break; + } + if tokio::fs::remove_file(&orphan.path).await.is_err() { + continue; + } + total = total.saturating_sub(orphan.size); + removed.push(orphan.path.to_string_lossy().to_string()); + if let Some(parent) = orphan.path.parent() { + super::fs_utils::prune_empty_dirs_up_to(parent, root); + } + } + super::fs_utils::prune_empty_subdirs_under(root); + removed +} + +/// Evict unindexed ephemeral cache files (oldest first) until tier size ≤ `max_bytes`. +#[tauri::command] +pub async fn evict_ephemeral_cache_orphans_to_fit( + keep_paths: Vec, + max_bytes: u64, + media_dir: Option, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let root = media_root.join(LocalTier::Ephemeral.subdir()); + Ok(evict_orphan_files_under_root_to_fit(&root, &keep_paths, max_bytes).await) +} + +/// Remove ephemeral-tier files under `{media}/cache/` not listed in `keep_paths`. +#[tauri::command] +pub async fn prune_orphan_ephemeral_cache_files( + keep_paths: Vec, + media_dir: Option, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let root = media_root.join(LocalTier::Ephemeral.subdir()); + Ok(prune_orphan_files_under_root(&root, &keep_paths).await) +} + +/// Batch existence probe for reconcile (index rows without on-disk bytes). +#[tauri::command] +pub fn probe_media_files(local_paths: Vec) -> Vec { + local_paths + .iter() + .map(|p| std::path::Path::new(p).is_file()) + .collect() +} + +fn resolve_media_tier_root( + tier: LocalTier, + media_dir: Option<&str>, + app: &AppHandle, +) -> Result { + Ok(resolve_media_dir(media_dir, app)?.join(tier.subdir())) +} + +/// Recursive byte size under `{media}/{cache|library}/`. +#[tauri::command] +pub async fn get_media_tier_size( + tier: String, + media_dir: Option, + app: AppHandle, +) -> u64 { + let local_tier = match LocalTier::parse(&tier) { + Some(t) => t, + None => return 0, + }; + resolve_media_tier_root(local_tier, media_dir.as_deref(), &app) + .map(|root| super::fs_utils::dir_size_recursive(&root)) + .unwrap_or(0) +} + +/// Deletes the entire `{cache|library}/` subtree under the media root. +#[tauri::command] +pub async fn purge_media_tier( + tier: String, + media_dir: Option, + app: AppHandle, +) -> Result<(), String> { + let local_tier = LocalTier::parse(&tier).ok_or_else(|| format!("unknown local tier: `{tier}`"))?; + let root = resolve_media_tier_root(local_tier, media_dir.as_deref(), &app)?; + if root.exists() { + tokio::fs::remove_dir_all(&root) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn prune_parents_after_media_file_delete( + file_path: &Path, + media_dir: Option<&str>, + app: &AppHandle, +) { + let Some(parent) = file_path.parent() else { + return; + }; + if let Some(boundary) = super::fs_utils::local_tier_boundary_from_path(file_path) { + super::fs_utils::prune_empty_dirs_up_to(parent, &boundary); + return; + } + if let Ok(media_root) = resolve_media_dir(media_dir, app) { + for tier in [LocalTier::Ephemeral, LocalTier::Library, LocalTier::Favorites] { + let boundary = media_root.join(tier.subdir()); + super::fs_utils::prune_empty_dirs_up_to(parent, &boundary); + } + } +} + +/// Deletes one media file and prunes empty parents up to the tier root. +#[tauri::command] +pub async fn delete_media_file( + local_path: String, + media_dir: Option, + app: AppHandle, +) -> Result<(), String> { + let file_path = std::path::PathBuf::from(&local_path); + if file_path.is_file() { + tokio::fs::remove_file(&file_path) + .await + .map_err(|e| e.to_string())?; + } + prune_parents_after_media_file_delete(&file_path, media_dir.as_deref(), &app); + Ok(()) +} + +/// Removes empty directories under `{media}/{cache|library}/` (post-eviction sweep). +#[tauri::command] +pub async fn prune_empty_media_tier_dirs( + tier: String, + media_dir: Option, + app: AppHandle, +) -> Result<(), String> { + let local_tier = + LocalTier::parse(&tier).ok_or_else(|| format!("unknown local tier: `{tier}`"))?; + let root = resolve_media_tier_root(local_tier, media_dir.as_deref(), &app)?; + super::fs_utils::prune_empty_subdirs_under(&root); + Ok(()) +} + +/// Promotes stream-cache bytes into `{media}/cache/…` using library-index paths. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn promote_stream_cache_to_local( + track_id: String, + server_index_key: String, + library_server_id: String, + url: String, + suffix: String, + media_dir: Option, + runtime: State<'_, LibraryRuntime>, + app: AppHandle, + state: State<'_, audio::AudioEngine>, +) -> Result, String> { + let repo = TrackRepository::new(&runtime.store); + let Some(row) = repo.find_one(&library_server_id, &track_id)? else { + return Ok(None); + }; + let path_input = track_row_to_path_input(&row); + let fingerprint = layout_fingerprint(&path_input); + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let file_path = absolute_track_path( + &media_root, + LocalTier::Ephemeral, + &server_index_key, + &path_input, + &suffix, + ); + let path_str = file_path.to_string_lossy().to_string(); + + ensure_track_path_within_tier(&media_root, LocalTier::Ephemeral, &file_path) + .map_err(|e| e.to_string())?; + + if file_path.is_file() { + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + return Ok(Some(LocalTrackDownloadResult { + path: path_str, + size, + layout_fingerprint: fingerprint, + })); + } + + let _track_guard = acquire_per_track_download_lock(&per_track_download_lock_key( + LocalTier::Ephemeral, + &server_index_key, + &track_id, + )) + .await; + + if file_path.is_file() { + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + return Ok(Some(LocalTrackDownloadResult { + path: path_str, + size, + layout_fingerprint: fingerprint, + })); + } + + if let Some(parent) = file_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + + let part_path = unique_part_path(&file_path, &suffix, &track_id); + + if let Some(bytes) = audio::take_stream_completed_for_url(&state, &url) { + if let Err(e) = tokio::fs::write(&part_path, &bytes).await { + let _ = tokio::fs::remove_file(&part_path).await; + return Err(e.to_string()); + } + tokio::fs::rename(&part_path, &file_path) + .await + .map_err(|e| e.to_string())?; + let priority = psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority( + &app, + &library_server_id, + &track_id, + None, + ); + let format_hint = Some(suffix.to_ascii_lowercase()); + let _ = enqueue_track_analysis( + &app, + &library_server_id, + &track_id, + &bytes, + format_hint.as_deref(), + priority, + ) + .await; + } else if let Some(spill_path) = audio::take_stream_completed_spill_for_url(&state, &url) { + if let Err(e) = tokio::fs::rename(&spill_path, &file_path).await { + if let Err(copy_err) = tokio::fs::copy(&spill_path, &file_path).await { + let _ = tokio::fs::remove_file(&spill_path).await; + return Err(format!("promote spill rename: {e}; copy: {copy_err}")); + } + let _ = tokio::fs::remove_file(&spill_path).await; + } + enqueue_analysis_seed_from_file( + &app, + &library_server_id, + &track_id, + &file_path, + Some(AnalysisBackfillPriority::Middle), + ) + .await; + } else { + return Ok(None); + } + + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + Ok(Some(LocalTrackDownloadResult { + path: path_str, + size, + layout_fingerprint: fingerprint, + })) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyOfflineMigrationResult { + pub track_id: String, + pub server_index_key: String, + pub path: String, + pub size: u64, + pub layout_fingerprint: String, + pub relocated: bool, + pub skipped_reason: Option, +} + +fn default_legacy_offline_root(app: &AppHandle) -> Option { + app.path().app_data_dir().ok().map(|d| d.join("psysonic-offline")) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyOfflineDiskEntry { + pub server_segment: String, + pub track_id: String, + pub path: String, + pub suffix: String, + pub size_bytes: u64, +} + +fn scan_flat_offline_root(root: &std::path::Path) -> Vec { + let mut out = Vec::new(); + if !root.is_dir() { + return out; + } + let Ok(server_dirs) = std::fs::read_dir(root) else { + return out; + }; + for server_entry in server_dirs.flatten() { + let server_path = server_entry.path(); + if !server_path.is_dir() { + continue; + } + let server_segment = server_entry.file_name().to_string_lossy().to_string(); + let Ok(files) = std::fs::read_dir(&server_path) else { + continue; + }; + for file_entry in files.flatten() { + let path = file_entry.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some((track_id, suffix)) = name.rsplit_once('.') else { + continue; + }; + if track_id.is_empty() || suffix.is_empty() { + continue; + } + let size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + out.push(LegacyOfflineDiskEntry { + server_segment: server_segment.clone(), + track_id: track_id.to_string(), + path: path.to_string_lossy().to_string(), + suffix: suffix.to_string(), + size_bytes, + }); + } + } + out +} + +fn legacy_offline_roots( + app: &AppHandle, + custom_offline_dir: Option<&str>, +) -> Vec { + let mut roots = Vec::new(); + if let Some(root) = default_legacy_offline_root(app) { + roots.push(root); + } + if let Some(cd) = custom_offline_dir.filter(|s| !s.is_empty()) { + let custom = std::path::PathBuf::from(cd); + if roots.iter().all(|r| r != &custom) { + roots.push(custom); + } + } + roots +} + +fn server_index_key_from_url(url: &str) -> String { + let trimmed = url.trim().trim_end_matches('/'); + trimmed + .strip_prefix("https://") + .or_else(|| trimmed.strip_prefix("http://")) + .unwrap_or(trimmed) + .to_string() +} + +fn base_url_for_server(runtime: &LibraryRuntime, server_id: &str) -> Option { + runtime + .sync_sessions + .lock() + .ok() + .and_then(|sessions| sessions.get(server_id).map(|s| s.base_url.clone())) +} + +fn server_index_key_for_disk( + runtime: &LibraryRuntime, + server_id: &str, + disk_segment: &str, +) -> String { + if let Some(url) = base_url_for_server(runtime, server_id) { + let key = server_index_key_from_url(&url); + if !key.is_empty() { + return key; + } + } + disk_segment.to_string() +} + +fn disk_segment_matches(disk_segment: &str, server_id: &str, index_key: &str) -> bool { + if disk_segment == server_id || disk_segment == index_key { + return true; + } + sanitize_path_segment(disk_segment) == sanitize_path_segment(index_key) + || sanitize_path_segment(disk_segment) == sanitize_path_segment(server_id) +} + +fn resolve_track_for_disk_file( + repo: &TrackRepository, + runtime: &LibraryRuntime, + disk_segment: &str, + track_id: &str, +) -> Result, String> { + if let Some(row) = repo.find_one(disk_segment, track_id)? { + let key = server_index_key_for_disk(runtime, &row.server_id, disk_segment); + return Ok(Some((row, key))); + } + let candidates = repo.find_live_by_id(track_id)?; + if candidates.is_empty() { + return Ok(None); + } + for row in &candidates { + let key = server_index_key_for_disk(runtime, &row.server_id, disk_segment); + if disk_segment_matches(disk_segment, &row.server_id, &key) { + return Ok(Some((row.clone(), key))); + } + } + if candidates.len() == 1 { + let row = candidates[0].clone(); + let key = server_index_key_for_disk(runtime, &row.server_id, disk_segment); + return Ok(Some((row, key))); + } + Ok(None) +} + +fn passes_server_filter( + filter: Option<&str>, + disk_segment: &str, + server_index_key: &str, +) -> bool { + let Some(filter) = filter.filter(|s| !s.is_empty()) else { + return true; + }; + disk_segment == filter + || server_index_key == filter + || sanitize_path_segment(disk_segment) == sanitize_path_segment(filter) +} + +/// Move `old_path` → `target_path` when needed (rename, or copy+delete on EXDEV). +async fn relocate_file_to_target( + old_path: &std::path::Path, + target_path: &std::path::Path, +) -> Result { + if old_path == target_path { + return Ok(false); + } + if target_path.is_file() { + if old_path.is_file() && old_path != target_path { + let _ = tokio::fs::remove_file(old_path).await; + } + return Ok(old_path != target_path); + } + if !old_path.is_file() { + return Err("SOURCE_MISSING".to_string()); + } + if let Some(parent) = target_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + match tokio::fs::rename(old_path, target_path).await { + Ok(()) => Ok(true), + Err(e) if e.raw_os_error() == Some(18) => { + tokio::fs::copy(old_path, target_path) + .await + .map_err(|e| e.to_string())?; + tokio::fs::remove_file(old_path) + .await + .map_err(|e| e.to_string())?; + Ok(true) + } + Err(e) => Err(e.to_string()), + } +} + +fn prune_legacy_offline_parents(old_path: &std::path::Path, app: &AppHandle) { + let Some(legacy_root) = default_legacy_offline_root(app) else { + return; + }; + let Some(parent) = old_path.parent() else { + return; + }; + if parent.starts_with(&legacy_root) { + super::fs_utils::prune_empty_dirs_up_to(parent, &legacy_root); + } +} + +struct RelocateLegacyTrackFile<'a> { + track_id: &'a str, + server_index_key: &'a str, + old_path: &'a Path, + suffix: &'a str, + row: &'a TrackRow, + media_root: &'a Path, + library_boundary: &'a Path, + app: &'a AppHandle, +} + +async fn relocate_legacy_track_file( + args: RelocateLegacyTrackFile<'_>, +) -> LegacyOfflineMigrationResult { + let path_input = track_row_to_path_input(args.row); + let fingerprint = layout_fingerprint(&path_input); + let target_path = absolute_track_path( + args.media_root, + LocalTier::Library, + args.server_index_key, + &path_input, + args.suffix, + ); + let target_str = target_path.to_string_lossy().to_string(); + let old_path_str = args.old_path.to_string_lossy().to_string(); + + if args.old_path.is_file() && args.old_path == target_path { + let size = tokio::fs::metadata(&target_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + return LegacyOfflineMigrationResult { + track_id: args.track_id.to_string(), + server_index_key: args.server_index_key.to_string(), + path: target_str, + size, + layout_fingerprint: fingerprint, + relocated: false, + skipped_reason: None, + }; + } + + if target_path.is_file() { + if args.old_path.is_file() && args.old_path != target_path { + let _ = tokio::fs::remove_file(args.old_path).await; + prune_legacy_offline_parents(args.old_path, args.app); + } + let size = tokio::fs::metadata(&target_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + return LegacyOfflineMigrationResult { + track_id: args.track_id.to_string(), + server_index_key: args.server_index_key.to_string(), + path: target_str, + size, + layout_fingerprint: fingerprint, + relocated: args.old_path.is_file(), + skipped_reason: None, + }; + } + + match relocate_file_to_target(args.old_path, &target_path).await { + Ok(relocated) => { + if relocated { + prune_legacy_offline_parents(args.old_path, args.app); + if let Some(parent) = target_path.parent() { + super::fs_utils::prune_empty_dirs_up_to(parent, args.library_boundary); + } + } + let size = if target_path.is_file() { + tokio::fs::metadata(&target_path) + .await + .map(|m| m.len()) + .unwrap_or(0) + } else { + 0 + }; + LegacyOfflineMigrationResult { + track_id: args.track_id.to_string(), + server_index_key: args.server_index_key.to_string(), + path: target_str, + size, + layout_fingerprint: fingerprint, + relocated, + skipped_reason: if target_path.is_file() { + None + } else { + Some("source_missing".to_string()) + }, + } + } + Err(reason) => LegacyOfflineMigrationResult { + track_id: args.track_id.to_string(), + server_index_key: args.server_index_key.to_string(), + path: old_path_str, + size: 0, + layout_fingerprint: fingerprint, + relocated: false, + skipped_reason: Some(reason), + }, + } +} + +/// Scan `psysonic-offline/{segment}/{trackId}.ext`, verify each id in the library +/// index, and relocate live tracks into `{media}/library/…`. +#[tauri::command] +pub async fn migrate_legacy_offline_disk( + media_dir: Option, + custom_offline_dir: Option, + server_index_key_filter: Option, + runtime: State<'_, LibraryRuntime>, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let library_boundary = media_root.join(LocalTier::Library.subdir()); + let repo = TrackRepository::new(&runtime.store); + let filter = server_index_key_filter.as_deref(); + + let mut disk_files = Vec::new(); + for root in legacy_offline_roots(&app, custom_offline_dir.as_deref()) { + disk_files.extend(scan_flat_offline_root(&root)); + } + + let mut out = Vec::with_capacity(disk_files.len()); + for file in disk_files { + let suffix = file.suffix.trim().trim_start_matches('.'); + let suffix = if suffix.is_empty() { "mp3" } else { suffix }; + let old_path = std::path::PathBuf::from(&file.path); + + let Some((row, server_index_key)) = + resolve_track_for_disk_file(&repo, &runtime, &file.server_segment, &file.track_id)? + else { + out.push(LegacyOfflineMigrationResult { + track_id: file.track_id, + server_index_key: file.server_segment.clone(), + path: file.path, + size: file.size_bytes, + layout_fingerprint: String::new(), + relocated: false, + skipped_reason: Some("library_track_not_found".to_string()), + }); + continue; + }; + + if !passes_server_filter(filter, &file.server_segment, &server_index_key) { + continue; + } + + out.push( + relocate_legacy_track_file(RelocateLegacyTrackFile { + track_id: &file.track_id, + server_index_key: &server_index_key, + old_path: &old_path, + suffix, + row: &row, + media_root: &media_root, + library_boundary: &library_boundary, + app: &app, + }) + .await, + ); + } + + Ok(out) +} + +#[cfg(test)] +mod migrate_tests { + use super::*; + + #[test] + fn scan_flat_offline_root_lists_track_files() { + let base = std::env::temp_dir().join(format!( + "psysonic-scan-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let track = base.join("my.server").join("abc123.flac"); + std::fs::create_dir_all(track.parent().unwrap()).unwrap(); + std::fs::write(&track, b"x").unwrap(); + let found = scan_flat_offline_root(&base); + assert_eq!(found.len(), 1); + assert_eq!(found[0].track_id, "abc123"); + assert_eq!(found[0].suffix, "flac"); + assert_eq!(found[0].server_segment, "my.server"); + let _ = std::fs::remove_dir_all(&base); + } + + #[tokio::test(flavor = "multi_thread")] + async fn evict_ephemeral_cache_orphans_to_fit_removes_oldest_first_when_over_budget() { + let base = std::env::temp_dir().join(format!( + "psysonic-ephemeral-evict-age-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let cache = base.join("cache"); + let keep = cache.join("srv").join("keep.flac"); + let old_orphan = cache.join("srv").join("old.flac"); + let new_orphan = cache.join("srv").join("new.flac"); + std::fs::create_dir_all(keep.parent().unwrap()).unwrap(); + std::fs::write(&keep, b"keep").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + std::fs::write(&old_orphan, b"oldorphan!").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + std::fs::write(&new_orphan, b"new!!").unwrap(); + + let removed = + evict_orphan_files_under_root_to_fit(&cache, &[keep.to_string_lossy().to_string()], 10) + .await; + + assert_eq!(removed.len(), 1); + assert!(removed[0].contains("old.flac")); + assert!(keep.is_file()); + assert!(!old_orphan.exists()); + assert!(new_orphan.is_file()); + let _ = std::fs::remove_dir_all(&base); + } + + #[tokio::test(flavor = "multi_thread")] + async fn evict_ephemeral_cache_orphans_to_fit_noop_when_under_budget() { + let base = std::env::temp_dir().join(format!( + "psysonic-ephemeral-evict-noop-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let cache = base.join("cache"); + let keep = cache.join("srv").join("keep.flac"); + let orphan = cache.join("srv").join("extra.flac"); + std::fs::create_dir_all(keep.parent().unwrap()).unwrap(); + std::fs::write(&keep, b"keep").unwrap(); + std::fs::write(&orphan, b"x").unwrap(); + + let removed = + evict_orphan_files_under_root_to_fit(&cache, &[keep.to_string_lossy().to_string()], 100) + .await; + + assert!(removed.is_empty()); + assert!(orphan.is_file()); + let _ = std::fs::remove_dir_all(&base); + } + + #[tokio::test(flavor = "multi_thread")] + async fn prune_orphan_ephemeral_cache_removes_untracked_files_and_empty_dirs() { + let base = std::env::temp_dir().join(format!( + "psysonic-ephemeral-prune-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let keep = base + .join("cache") + .join("srv") + .join("Artist") + .join("Album") + .join("01 - Keep.flac"); + let orphan = base + .join("cache") + .join("srv") + .join("Artist") + .join("Album") + .join("02 - Drop.flac"); + let orphan_part = base + .join("cache") + .join("srv") + .join("Other") + .join("stale.flac.part"); + std::fs::create_dir_all(keep.parent().unwrap()).unwrap(); + std::fs::create_dir_all(orphan_part.parent().unwrap()).unwrap(); + std::fs::write(&keep, b"keep").unwrap(); + std::fs::write(&orphan, b"drop").unwrap(); + std::fs::write(&orphan_part, b"part").unwrap(); + + let removed = prune_orphan_files_under_root( + &base.join("cache"), + &[keep.to_string_lossy().to_string()], + ) + .await; + + assert_eq!(removed.len(), 2); + assert!(keep.is_file()); + assert!(!orphan.exists()); + assert!(!orphan_part.exists()); + assert!(!base.join("cache/srv/Other").exists()); + let _ = std::fs::remove_dir_all(&base); + } + + #[tokio::test(flavor = "multi_thread")] + async fn relocate_moves_file_to_nested_target() { + let base = std::env::temp_dir().join(format!( + "psysonic-migrate-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let old = base.join("psysonic-offline").join("srv").join("t1.mp3"); + let target = base + .join("media") + .join("library") + .join("Artist") + .join("Album") + .join("01 - Song.mp3"); + std::fs::create_dir_all(old.parent().unwrap()).unwrap(); + std::fs::write(&old, b"abc").unwrap(); + let relocated = relocate_file_to_target(&old, &target).await.unwrap(); + assert!(relocated); + assert!(target.is_file()); + assert!(!old.exists()); + let _ = std::fs::remove_dir_all(&base); + } +} + diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs b/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs index c8f0f071..75c26f78 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs @@ -2,3 +2,4 @@ mod fs_utils; pub mod offline; pub mod downloads; pub mod hot; +pub mod local; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d46d5ddd..ed8c2af4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -715,6 +715,7 @@ pub fn run() { psysonic_library::commands::library_get_track, psysonic_library::commands::library_get_tracks_batch, psysonic_library::commands::library_get_tracks_by_album, + psysonic_library::commands::library_upsert_songs_from_api, psysonic_library::commands::library_get_artifact, psysonic_library::commands::library_get_facts, psysonic_library::commands::library_get_offline_path, @@ -775,6 +776,19 @@ pub fn run() { psysonic_syncfs::cache::offline::clear_offline_cancel, psysonic_syncfs::cache::offline::delete_offline_track, psysonic_syncfs::cache::offline::get_offline_cache_size, + psysonic_syncfs::cache::local::download_track_local, + psysonic_syncfs::cache::local::probe_library_track_local, + psysonic_syncfs::cache::local::discover_library_tier_on_disk, + psysonic_syncfs::cache::local::prune_orphan_library_tier_files, + psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files, + psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit, + psysonic_syncfs::cache::local::probe_media_files, + psysonic_syncfs::cache::local::get_media_tier_size, + psysonic_syncfs::cache::local::purge_media_tier, + psysonic_syncfs::cache::local::delete_media_file, + psysonic_syncfs::cache::local::prune_empty_media_tier_dirs, + psysonic_syncfs::cache::local::promote_stream_cache_to_local, + psysonic_syncfs::cache::local::migrate_legacy_offline_disk, psysonic_syncfs::cache::hot::download_track_hot_cache, psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache, psysonic_syncfs::cache::hot::get_hot_cache_size, diff --git a/src/api/library.ts b/src/api/library.ts index 880efbfd..7f67825f 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -473,6 +473,18 @@ export function libraryGetTrack( .then(track => (track ? { ...track, serverId } : track)); } +/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */ +export function libraryUpsertSongsFromApi( + serverId: string, + songs: unknown[], +): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_upsert_songs_from_api', { serverId: indexKey, songs }); +} + +/** `library_get_tracks_batch` cap (spec §7.1). */ +export const LIBRARY_TRACKS_BATCH_LIMIT = 100; + export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise { const indexKeyMap = new Map(); const remapped = refs.map(ref => { @@ -487,6 +499,18 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise { + if (refs.length === 0) return []; + const out: LibraryTrackDto[] = []; + for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) { + const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT); + const batch = await libraryGetTracksBatch(chunk).catch(() => []); + out.push(...batch); + } + return out; +} + export function libraryGetTracksByAlbum( serverId: string, albumId: string, diff --git a/src/api/subsonic.async.test.ts b/src/api/subsonic.async.test.ts index 3a7bb35b..7f617e66 100644 --- a/src/api/subsonic.async.test.ts +++ b/src/api/subsonic.async.test.ts @@ -20,6 +20,11 @@ vi.mock('axios', () => ({ default: { get: vi.fn() }, })); +vi.mock('../utils/network/subsonicNetworkGuard', () => ({ + shouldAttemptSubsonicForActiveServer: () => true, + shouldAttemptSubsonicForServer: () => true, +})); + import axios from 'axios'; import { pingWithCredentials, ping } from './subsonic'; import { getAlbumInfo2 } from './subsonicAlbumInfo'; diff --git a/src/api/subsonicLibrary.ts b/src/api/subsonicLibrary.ts index dcb4445a..386117e0 100644 --- a/src/api/subsonicLibrary.ts +++ b/src/api/subsonicLibrary.ts @@ -1,4 +1,8 @@ import { useAuthStore } from '../store/authStore'; +import { + shouldAttemptSubsonicForActiveServer, + shouldAttemptSubsonicForServer, +} from '../utils/network/subsonicNetworkGuard'; import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient'; import type { RandomSongsFilters, @@ -57,6 +61,7 @@ export async function getMusicFolders(): Promise { } export async function getRandomAlbums(size = 6): Promise { + if (!shouldAttemptSubsonicForActiveServer()) return []; const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size, @@ -71,6 +76,7 @@ export async function getAlbumList( offset = 0, extra: Record = {} ): Promise { + if (!shouldAttemptSubsonicForActiveServer()) return []; const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, @@ -212,6 +218,7 @@ export async function getAlbumListForServer( offset = 0, extra: Record = {}, ): Promise { + if (!shouldAttemptSubsonicForServer(serverId)) return []; const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', { type, size, @@ -224,6 +231,7 @@ export async function getAlbumListForServer( } export async function getSong(id: string): Promise { + if (!shouldAttemptSubsonicForActiveServer()) return null; try { const data = await api<{ song: SubsonicSong }>('getSong.view', { id }); return data.song ?? null; @@ -233,6 +241,7 @@ export async function getSong(id: string): Promise { } export async function getSongForServer(serverId: string, id: string): Promise { + if (!shouldAttemptSubsonicForServer(serverId, id)) return null; try { const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id }); return data.song ?? null; @@ -242,6 +251,9 @@ export async function getSongForServer(serverId: string, id: string): Promise { + if (!shouldAttemptSubsonicForActiveServer()) { + throw new Error('Subsonic unavailable'); + } const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id }); const { song, ...album } = data.album; return { album, songs: song ?? [] }; @@ -251,6 +263,9 @@ export async function getAlbumForServer( serverId: string, id: string, ): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { + if (!shouldAttemptSubsonicForServer(serverId)) { + throw new Error('Subsonic unavailable'); + } const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id }); const { song, ...album } = data.album; return { album, songs: song ?? [] }; diff --git a/src/api/subsonicPlaylists.ts b/src/api/subsonicPlaylists.ts index 8a24392f..26efd72c 100644 --- a/src/api/subsonicPlaylists.ts +++ b/src/api/subsonicPlaylists.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; -import { api } from './subsonicClient'; +import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard'; +import { api, apiForServer } from './subsonicClient'; import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes'; export async function getPlaylists(includeOrbit = false): Promise { @@ -19,6 +20,22 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl return { playlist, songs: entry ?? [] }; } +export async function getPlaylistForServer( + serverId: string, + id: string, +): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> { + if (!shouldAttemptSubsonicForServer(serverId)) { + throw new Error('Subsonic unavailable'); + } + const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>( + serverId, + 'getPlaylist.view', + { id }, + ); + const { entry, ...playlist } = data.playlist; + return { playlist, songs: entry ?? [] }; +} + export async function createPlaylist(name: string, songIds?: string[]): Promise { const params: Record = { name }; if (songIds && songIds.length > 0) { @@ -40,6 +57,9 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount = songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i), }); } + void import('../utils/offline/pinnedOfflineSync') + .then(m => m.schedulePinnedPlaylistSync(id)) + .catch(() => {}); } export async function updatePlaylistMeta( diff --git a/src/api/subsonicScrobble.test.ts b/src/api/subsonicScrobble.test.ts index 8f6cf35a..7478bf75 100644 --- a/src/api/subsonicScrobble.test.ts +++ b/src/api/subsonicScrobble.test.ts @@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({ api: vi.fn(), apiForServer: apiForServerMock, })); +vi.mock('../utils/network/subsonicNetworkGuard', () => ({ + shouldAttemptSubsonicForServer: () => true, +})); describe('subsonicScrobble', () => { beforeEach(() => { diff --git a/src/api/subsonicScrobble.ts b/src/api/subsonicScrobble.ts index 27077337..5333abd6 100644 --- a/src/api/subsonicScrobble.ts +++ b/src/api/subsonicScrobble.ts @@ -1,6 +1,7 @@ import { api, apiForServer } from './subsonicClient'; import type { SubsonicNowPlaying } from './subsonicTypes'; import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse'; +import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard'; async function scrobbleOnServer( serverId: string, @@ -8,6 +9,7 @@ async function scrobbleOnServer( submission: boolean, time?: number, ): Promise { + if (!shouldAttemptSubsonicForServer(serverId, id)) return; const params: Record = { id, submission }; if (time !== undefined) params.time = time; await apiForServer(serverId, 'scrobble.view', params); diff --git a/src/api/subsonicStarRating.ts b/src/api/subsonicStarRating.ts index 2f41627a..7ed05c0d 100644 --- a/src/api/subsonicStarRating.ts +++ b/src/api/subsonicStarRating.ts @@ -1,4 +1,4 @@ -import { api, libraryFilterParams } from './subsonicClient'; +import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient'; import { invalidateEntityUserRatingCaches } from './subsonicRatings'; import { useAuthStore } from '../store/authStore'; import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse'; @@ -15,6 +15,17 @@ import type { SubsonicSong, } from './subsonicTypes'; +function parseStarred2Response(data: { + starred2?: { + artist?: SubsonicArtist[]; + album?: SubsonicAlbum[]; + song?: SubsonicSong[]; + }; +}): StarredResults { + const r = data.starred2 ?? {}; + return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; +} + export async function getStarred(): Promise { const data = await api<{ starred2: { @@ -23,21 +34,50 @@ export async function getStarred(): Promise { song?: SubsonicSong[]; } }>('getStarred2.view', { ...libraryFilterParams() }); - const r = data.starred2 ?? {}; - return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; + return parseStarred2Response(data); +} + +/** Starred entities for an explicit saved server (not necessarily the active one). */ +export async function getStarredForServer(serverId: string): Promise { + const data = await apiForServer<{ + starred2: { + artist?: SubsonicArtist[]; + album?: SubsonicAlbum[]; + song?: SubsonicSong[]; + }; + }>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) }); + return parseStarred2Response(data); +} + +function resolveStarServerId(meta?: StarPatchMeta): string | null { + return meta?.serverId ?? useAuthStore.getState().activeServerId; +} + +async function starApi( + serverId: string | null | undefined, + endpoint: string, + params: Record, +): Promise { + const sid = serverId ?? useAuthStore.getState().activeServerId; + if (!sid) throw new Error('No server for star API'); + if (sid === useAuthStore.getState().activeServerId) { + await api(endpoint, params); + } else { + await apiForServer(sid, endpoint, params); + } } export async function star( id: string, type: 'song' | 'album' | 'artist' = 'album', - _meta?: StarPatchMeta, + meta?: StarPatchMeta, ): Promise { const params: Record = {}; if (type === 'song') params.id = id; if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; - await api('star.view', params); - const serverId = useAuthStore.getState().activeServerId; + const serverId = resolveStarServerId(meta); + await starApi(serverId, 'star.view', params); if (type === 'song') { patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() }); } else if (type === 'album' && serverId) { @@ -45,19 +85,22 @@ export async function star( const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId); void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {}); } + void import('../utils/offline/favoritesOfflineSync') + .then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined)) + .catch(() => {}); } export async function unstar( id: string, type: 'song' | 'album' | 'artist' = 'album', - _meta?: StarPatchMeta, + meta?: StarPatchMeta, ): Promise { const params: Record = {}; if (type === 'song') params.id = id; if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; - await api('unstar.view', params); - const serverId = useAuthStore.getState().activeServerId; + const serverId = resolveStarServerId(meta); + await starApi(serverId, 'unstar.view', params); if (type === 'song') { patchLibraryTrackOnUse(serverId, id, { starredAt: null }); } else if (type === 'album' && serverId) { @@ -65,6 +108,9 @@ export async function unstar( const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId); void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {}); } + void import('../utils/offline/favoritesOfflineSync') + .then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined)) + .catch(() => {}); } export async function setRating(id: string, rating: number): Promise { diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 5e3297bc..011424f4 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -26,6 +26,8 @@ export interface SubsonicAlbum { displayArtist?: string; /** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */ discTitles?: SubsonicDiscTitle[]; + /** Set when favorites are merged across servers (offline favorites tier). */ + serverId?: string; } export interface SubsonicDiscTitle { @@ -90,6 +92,8 @@ export interface SubsonicSong { trackPeak?: number; albumPeak?: number; }; + /** Set when favorites are merged across servers (offline favorites tier). */ + serverId?: string; /** OpenSubsonic: structured composer credit (string for back-compat). */ displayComposer?: string; /** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */ @@ -144,6 +148,8 @@ export interface SubsonicArtist { starred?: string; /** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */ userRating?: number; + /** Set when favorites are merged across servers (offline favorites tier). */ + serverId?: string; } export interface SubsonicGenre { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 97b075b4..446d78ac 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -38,7 +38,11 @@ import { useOrbitHost } from '../hooks/useOrbitHost'; import { useOrbitGuest } from '../hooks/useOrbitGuest'; import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs'; import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup'; +import { + hasOfflineBrowsingContent, +} from '../utils/offline/favoritesOfflineBrowse'; import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState'; import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle'; import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n'; @@ -106,7 +110,12 @@ export function AppShell() { useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const offlineAlbums = useOfflineStore(s => s.albums); - const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); + const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled); + const activeServerId = useAuthStore(s => s.activeServerId); + const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId)); + const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled; + const hasManualOfflineContent = hasAnyOfflineAlbums(offlineAlbums); + const hasOfflineContent = hasOfflineBrowsingContent(offlineAlbums); const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); const perfFlags = usePerfProbeFlags(); @@ -134,7 +143,13 @@ export function AppShell() { document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); }, [location.pathname, location.state]); - useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate); + useOfflineAutoNav( + connStatus, + hasManualOfflineContent, + favoritesOfflineBrowse, + location.pathname, + navigate, + ); useEffect(() => { initializeFromServerQueue(); diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 51014eff..fce705f9 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -13,6 +13,12 @@ import { useAuthStore } from '../store/authStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useGlobalShortcutsStore } from '../store/globalShortcutsStore'; import { initHotCachePrefetch } from '../hotCachePrefetch'; +import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation'; +import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync'; +import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync'; +import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins'; +import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration'; +import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration'; import { bootstrapAllIndexedServers } from '../utils/library/librarySession'; @@ -98,6 +104,28 @@ export default function MainApp() { return initHotCachePrefetch(); }, [migrationReady]); + useEffect(() => { + if (!migrationReady) return undefined; + void (async () => { + await runLegacyOfflineFileMigration(); + const servers = useAuthStore.getState().servers; + for (const server of servers) { + await reconcileLibraryTierForServer(server.id); + } + scheduleResumeIncompleteOfflinePins(); + })(); + const stopInvalidation = initLocalPlaybackInvalidation(); + const stopFavoritesSync = initFavoritesOfflineSync(); + const stopPinnedOfflineSync = initPinnedOfflineSync(); + const stopOfflineResume = initResumeIncompleteOfflinePins(); + return () => { + stopInvalidation(); + stopFavoritesSync(); + stopPinnedOfflineSync(); + stopOfflineResume(); + }; + }, [migrationReady, serverIdsKey]); + useEffect(() => { if (!migrationReady) return; useGlobalShortcutsStore.getState().registerAll(); diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 7c5d2c70..d1cea6dc 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -7,8 +7,9 @@ import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum'; import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePlayerStore } from '../store/playerStore'; -import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; +import { useLocalPlaybackStore } from '../store/localPlaybackStore'; +import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers'; import { CoverArtImage } from '../cover/CoverArtImage'; import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; import { coverStorageKeyFromRef } from '../cover/storageKeys'; @@ -23,6 +24,8 @@ import { LongPressWaveOverlay } from './LongPressWaveOverlay'; import { useDragDrop } from '../contexts/DragDropContext'; import { isAlbumRecentlyAdded } from '../utils/albumRecency'; import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; +import { coverServerScopeForServerId } from '../cover/serverScope'; +import { appendServerQuery } from '../utils/navigation/detailServerScope'; interface AlbumCardProps { album: SubsonicAlbum; @@ -63,21 +66,28 @@ function AlbumCard({ }: AlbumCardProps) { const { t } = useTranslation(); const { isHolding, pressBind } = useLongPressAction({ - onShortPress: () => playAlbum(album.id), - onLongPress: () => playAlbumShuffled(album.id), + onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined), + onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined), }); const navigate = useNavigate(); const navigateToAlbum = useNavigateToAlbum(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const enqueue = usePlayerStore(s => s.enqueue); - const serverId = useAuthStore(s => s.activeServerId ?? ''); - const isOffline = useOfflineStore(s => { - const meta = s.albums[`${serverId}:${album.id}`]; - if (!meta || meta.trackIds.length === 0) return false; - return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); - }); + const activeServerId = useAuthStore(s => s.activeServerId ?? ''); + const offlineServerId = album.serverId ?? activeServerId; + const localEntries = useLocalPlaybackStore(s => s.entries); + const isOffline = isOfflinePinComplete(album.id, offlineServerId); + const albumLinkQuery = useMemo( + () => appendServerQuery(linkQuery, album.serverId), + [linkQuery, album.serverId], + ); + void localEntries; const psyDrag = useDragDrop(); - const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve }); + const coverServerScope = useMemo( + () => coverServerScopeForServerId(album.serverId), + [album.serverId], + ); + const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve }); const dragCoverKey = useMemo(() => { if (!coverRef) return ''; const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' }); @@ -88,7 +98,7 @@ function AlbumCard({ const handleClick = (opts?: { shiftKey?: boolean }) => { if (selectionMode) { onToggleSelect?.(album.id, opts); return; } - navigateToAlbum(album.id, { search: linkQuery }); + navigateToAlbum(album.id, { search: albumLinkQuery }); }; return ( diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index a3281cc1..fc9f50a4 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -74,7 +74,7 @@ interface AlbumHeaderProps { resolvedCoverUrl: string | null; isStarred: boolean; downloadProgress: number | null; - offlineStatus: 'none' | 'downloading' | 'cached'; + offlineStatus: 'none' | 'queued' | 'downloading' | 'cached'; offlineProgress: { done: number; total: number } | null; bio: string | null; bioOpen: boolean; @@ -300,6 +300,15 @@ export default function AlbumHeader({
+ ) : offlineStatus === 'queued' ? ( + ) : offlineStatus === 'cached' ? ( ) : offlineStatus === 'cached' ? ( )} - {albums.length > 0 && (() => { - const progress = id ? bulkProgress[id] : undefined; - const isDone = progress && progress.done === progress.total; - const isDownloading = progress && !isDone; - return ( - - ); - })()} + {albums.length > 0 && ( + + )} diff --git a/src/components/contextMenu/AlbumContextItems.tsx b/src/components/contextMenu/AlbumContextItems.tsx index b96431b4..ec79fc71 100644 --- a/src/components/contextMenu/AlbumContextItems.tsx +++ b/src/components/contextMenu/AlbumContextItems.tsx @@ -61,6 +61,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { const starred = isStarred(album.id, album.starred); setStarredOverride(album.id, !starred); const meta = { + serverId: album.serverId, name: album.name, artist: album.artist, artistId: album.artistId, diff --git a/src/components/contextMenu/ArtistContextItems.tsx b/src/components/contextMenu/ArtistContextItems.tsx index 9dfb6325..cf783467 100644 --- a/src/components/contextMenu/ArtistContextItems.tsx +++ b/src/components/contextMenu/ArtistContextItems.tsx @@ -54,7 +54,11 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
handleAction(() => { const starred = isStarred(artist.id, artist.starred); setStarredOverride(artist.id, !starred); - const meta = { name: artist.name, albumCount: artist.albumCount }; + const meta = { + serverId: artist.serverId, + name: artist.name, + albumCount: artist.albumCount, + }; return starred ? unstar(artist.id, 'artist', meta) : star(artist.id, 'artist', meta); diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index 1215ef8d..7b73f988 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -71,7 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
)}
handleAction(() => { - queueSongStar(song.id, !isStarred(song.id, song.starred)); + queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx index 55821081..2aca22eb 100644 --- a/src/components/contextMenu/SongContextItems.tsx +++ b/src/components/contextMenu/SongContextItems.tsx @@ -121,7 +121,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
handleAction(() => { - queueSongStar(song.id, !isStarred(song.id, song.starred)); + queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} @@ -303,7 +303,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => { - queueSongStar(song.id, false); + queueSongStar(song.id, false, song.serverId); })}> {t('contextMenu.unfavorite')}
diff --git a/src/components/favorites/FavoriteSongRow.tsx b/src/components/favorites/FavoriteSongRow.tsx index 5e069a2e..540c39b3 100644 --- a/src/components/favorites/FavoriteSongRow.tsx +++ b/src/components/favorites/FavoriteSongRow.tsx @@ -19,8 +19,8 @@ export interface FavoriteSongRowCallbacks { startPreview: (song: SubsonicSong) => void; rate: (songId: string, rating: number) => void; remove: (songId: string) => void; - navArtist: (artistId: string) => void; - navAlbum: (albumId: string) => void; + navArtist: (artistId: string, serverId?: string) => void; + navAlbum: (albumId: string, serverId?: string) => void; } interface Props { @@ -100,12 +100,12 @@ function FavoriteSongRow({ ); case 'artist': return (
- { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist} + { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId, song.serverId); } }}>{song.artist}
); case 'album': return (
- { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album} + { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId, song.serverId); } }}>{song.album}
); case 'genre': return ( diff --git a/src/components/favorites/FavoritesOfflineHeader.tsx b/src/components/favorites/FavoritesOfflineHeader.tsx new file mode 100644 index 00000000..83d22f19 --- /dev/null +++ b/src/components/favorites/FavoritesOfflineHeader.tsx @@ -0,0 +1,74 @@ +import { HardDrive } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../../store/authStore'; +import { + useFavoritesOfflineStatus, + type FavoritesOfflineSemaphore, +} from '../../hooks/useFavoritesOfflineStatus'; +import { + disableFavoritesOfflineSync, + scheduleFavoritesOfflineSync, +} from '../../utils/offline/favoritesOfflineSync'; + +function semaphoreTooltipKey(semaphore: FavoritesOfflineSemaphore): string { + switch (semaphore) { + case 'red': + return 'favorites.offlineSemaphoreError'; + case 'yellow': + return 'favorites.offlineSemaphoreSyncing'; + case 'green': + return 'favorites.offlineSemaphoreSynced'; + } +} + +export default function FavoritesOfflineHeader() { + const { t } = useTranslation(); + const setEnabled = useAuthStore(s => s.setFavoritesOfflineEnabled); + const { enabled, semaphore, savedCount, targetCount } = useFavoritesOfflineStatus(); + + const semaphoreLabel = semaphore + ? t(semaphoreTooltipKey(semaphore), { saved: savedCount, total: targetCount }) + : undefined; + + return ( +
+ {enabled && semaphore && ( + + )} +
+ + +
+
+ ); +} diff --git a/src/components/favorites/FavoritesSongsTracklist.tsx b/src/components/favorites/FavoritesSongsTracklist.tsx index 27ea9372..a72a5636 100644 --- a/src/components/favorites/FavoritesSongsTracklist.tsx +++ b/src/components/favorites/FavoritesSongsTracklist.tsx @@ -13,6 +13,7 @@ import { useThemeStore } from '../../store/themeStore'; import { useDragDrop } from '../../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; +import { appendServerQuery } from '../../utils/navigation/detailServerScope'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll'; import { useElementClientHeightById } from '../../hooks/useResizeClientHeight'; import { SORTABLE_COLUMNS } from '../../hooks/useFavoritesSongFiltering'; @@ -127,8 +128,14 @@ export default function FavoritesSongsTracklist({ ), rate: (songId, r) => latest.current.handleRate(songId, r), remove: (songId) => latest.current.removeSong(songId), - navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`), - navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`), + navArtist: (artistId, serverId) => { + const query = appendServerQuery(undefined, serverId); + latest.current.navigate(query ? `/artist/${artistId}?${query}` : `/artist/${artistId}`); + }, + navAlbum: (albumId, serverId) => { + const query = appendServerQuery(undefined, serverId); + latest.current.navigate(query ? `/album/${albumId}?${query}` : `/album/${albumId}`); + }, }), []); const listWrapRef = useRef(null); diff --git a/src/components/favorites/TopFavoriteArtists.tsx b/src/components/favorites/TopFavoriteArtists.tsx index 3b53a4e3..ffdebe71 100644 --- a/src/components/favorites/TopFavoriteArtists.tsx +++ b/src/components/favorites/TopFavoriteArtists.tsx @@ -4,12 +4,17 @@ import { ChevronLeft, ChevronRight, Users } from 'lucide-react'; import { CoverArtImage } from '../../cover/CoverArtImage'; import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage'; import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes'; +import { coverServerScopeForServerId } from '../../cover/serverScope'; export interface TopFavoriteArtist { id: string; name: string; count: number; coverArtId: string; + /** Present when favorites are merged across servers. */ + serverId?: string; + /** Raw artist id for song filtering (without server prefix). */ + artistId?: string; } interface TopFavoriteArtistsRowProps { @@ -84,6 +89,7 @@ interface TopFavoriteArtistCardProps { function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) { const coverId = artist.coverArtId; + const artistEntityId = artist.artistId ?? artist.coverArtId; return (
{coverId ? ( { if (!currentTrack) return; - queueSongStar(currentTrack.id, !isStarred); + queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId); }, [currentTrack, isStarred]); const duration = currentTrack?.duration ?? 0; diff --git a/src/components/playlist/PlaylistHero.tsx b/src/components/playlist/PlaylistHero.tsx index ea40b1b3..a0f68251 100644 --- a/src/components/playlist/PlaylistHero.tsx +++ b/src/components/playlist/PlaylistHero.tsx @@ -7,6 +7,8 @@ import { } from 'lucide-react'; import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes'; import type { ZipDownload } from '../../store/zipDownloadStore'; +import type { AlbumOfflineStatus } from '../../hooks/useAlbumOfflineState'; +import { dequeueOfflinePin } from '../../utils/offline/offlinePinQueue'; import { useThemeStore } from '../../store/themeStore'; import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore'; import { @@ -29,8 +31,7 @@ interface Props { searchOpen: boolean; csvImporting: boolean; activeZip: ZipDownload | undefined; - isCached: boolean; - isDownloading: boolean; + offlineStatus: AlbumOfflineStatus; offlineProgress: { done: number; total: number } | null; activeServerId: string; setEditingMeta: React.Dispatch>; @@ -52,7 +53,7 @@ export default function PlaylistHero({ playlist, songs, id, customCoverId, coverQuadIds, resolvedBgUrl, saving, searchOpen, csvImporting, activeZip, - isCached, isDownloading, offlineProgress, activeServerId, + offlineStatus, offlineProgress, activeServerId, setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults, setSelectedSearchIds, setSearchPlPickerOpen, handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload, @@ -206,27 +207,39 @@ export default function PlaylistHero({ ) )} - {isLayoutVisible('offlineCache') && songs.length > 0 && id && ( + {isLayoutVisible('offlineCache') && songs.length > 0 && id + && (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (