From 77dfd73b6d1518c4c4bebf5873e8bc70b9981954 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:32:29 -0600 Subject: [PATCH] fix(slint): network-folder visibility keyed on connectivity, playback lock on missing files Root cause of the empty Albums tab: transposed positional booleans from the Plex-port rewrite (a45840d1) left exclude_network_folders=true and include_qobuz_downloads=false unconditionally on every browse query since 2026-06-07. The owner-approved model replaces it: network-folder content hides ONLY under hard offline (link confirmed down - no link means no LAN mounts either); a logged-out session or induced offline with the link up shows everything (the Qobuz verdict says nothing about NFS/SMB). The show-network-folders toggle is retired from Settings (DB column stays, Tauri-compatible). Playback lock instead of hiding: local tracks get an existence check at the play seam and in the advance walk (FileMissing verdict, 'is the drive mounted?' toasts, never on the audio thread - D-state caveat documented). Scan-cleanup guard: tracks under a network folder whose mount is unreachable are never treated as missing (a refresh pressed before remounting used to hard-delete the folder's whole index). mount_info: nfs4 and smb3-style version suffixes now classify as network (its red test is green) and the longest-mount match respects path boundaries. cc --- crates/qbz-library/src/mount_info.rs | 101 ++++++++++---- crates/qbz-library/src/scan.rs | 43 ++++-- crates/qbz-slint/src/local_library.rs | 81 +++++++++-- .../qbz-slint/src/local_library_settings.rs | 39 +++--- crates/qbz-slint/src/myqbz_builder.rs | 9 +- crates/qbz-slint/src/playback.rs | 127 +++++++++++++++--- crates/qbz-slint/src/settings.rs | 34 +---- .../ui/settings/OfflineSettings.slint | 13 -- 8 files changed, 317 insertions(+), 130 deletions(-) diff --git a/crates/qbz-library/src/mount_info.rs b/crates/qbz-library/src/mount_info.rs index c30645002..0b54495a4 100644 --- a/crates/qbz-library/src/mount_info.rs +++ b/crates/qbz-library/src/mount_info.rs @@ -56,22 +56,9 @@ pub fn is_network_path(path: &Path) -> bool { let target = path .canonicalize() .unwrap_or_else(|_| path.to_path_buf()); - let target_str = target.to_string_lossy(); - // Longest-prefix match wins — `/` is always present but any - // deeper mount shadows it. - let mut best: Option<(&str, usize)> = None; - for (mount_point, fs_type) in &mounts { - if target_str.starts_with(mount_point) { - let len = mount_point.len(); - if best.map(|(_, l)| l < len).unwrap_or(true) { - best = Some((fs_type.as_str(), len)); - } - } - } - - match best { - Some((fs_type, _)) => is_network_fs(fs_type), + match best_fs_type(&mounts, &target.to_string_lossy()) { + Some(fs_type) => is_network_fs(fs_type), None => false, } } @@ -96,24 +83,48 @@ pub fn network_fs_label(path: &Path) -> Option { let target = path .canonicalize() .unwrap_or_else(|_| path.to_path_buf()); - let target_str = target.to_string_lossy(); - let mut best: Option<(&str, usize)> = None; - for (mount_point, fs_type) in &mounts { - if target_str.starts_with(mount_point) { - let len = mount_point.len(); - if best.map(|(_, l)| l < len).unwrap_or(true) { - best = Some((fs_type.as_str(), len)); - } - } - } - let fs_type = best.map(|(t, _)| t)?; + let fs_type = best_fs_type(&mounts, &target.to_string_lossy())?; if !is_network_fs(fs_type) { return None; } Some(normalize_network_label(fs_type)) } +/// Longest-mount-point match of `target` against the mount table, honoring +/// path-component boundaries: `/mnt/music` matches `/mnt/music` and +/// `/mnt/music/Albums/x.flac` but NOT `/mnt/music2` (the previous raw +/// `starts_with` matched the sibling too, inheriting the wrong fs type). +/// `/` is always present and any deeper mount shadows it. +#[cfg(target_os = "linux")] +fn best_fs_type<'a>(mounts: &'a [(String, String)], target: &str) -> Option<&'a str> { + let mut best: Option<(&'a str, usize)> = None; + for (mount_point, fs_type) in mounts { + if !path_within_mount(target, mount_point) { + continue; + } + let len = mount_point.len(); + if best.map(|(_, l)| l < len).unwrap_or(true) { + best = Some((fs_type.as_str(), len)); + } + } + best.map(|(t, _)| t) +} + +/// True when `target` IS `mount_point` or lives underneath it, on a path +/// component boundary. +#[cfg(target_os = "linux")] +fn path_within_mount(target: &str, mount_point: &str) -> bool { + if mount_point == "/" { + return target.starts_with('/'); + } + match target.strip_prefix(mount_point.trim_end_matches('/')) { + Some("") => true, + Some(rest) => rest.starts_with('/'), + None => false, + } +} + #[cfg(not(target_os = "linux"))] pub fn network_fs_label(_path: &Path) -> Option { None @@ -180,9 +191,20 @@ fn parse_mounts(contents: &str) -> Vec<(String, String)> { #[cfg(target_os = "linux")] fn is_network_fs(fs_type: &str) -> bool { + // A prefix hits on: the exact type ("nfs", "cifs"), a dotted scheme + // ("fuse.sshfs.x"), or a version suffix ("nfs4", "nfs3", "smb3" — pure + // digits after the prefix). The previous `== || starts_with("{prefix}.")` + // missed the version-suffixed forms, so `nfs4` — the fs type every + // modern NFS mount reports in /proc/mounts — classified as LOCAL. NETWORK_FS_PREFIXES .iter() - .any(|prefix| fs_type == *prefix || fs_type.starts_with(&format!("{}.", prefix))) + .any(|prefix| match fs_type.strip_prefix(prefix) { + Some("") => true, + Some(rest) => { + rest.starts_with('.') || rest.chars().all(|c| c.is_ascii_digit()) + } + None => false, + }) } #[cfg(all(test, target_os = "linux"))] @@ -207,6 +229,31 @@ mod tests { assert!(!is_network_fs("fuse.gocryptfs")); } + #[test] + fn best_fs_type_respects_path_boundaries() { + let mounts = vec![ + ("/".to_string(), "ext4".to_string()), + ("/mnt/music".to_string(), "nfs4".to_string()), + ]; + assert_eq!(best_fs_type(&mounts, "/mnt/music"), Some("nfs4")); + assert_eq!(best_fs_type(&mounts, "/mnt/music/Albums/x.flac"), Some("nfs4")); + // A sibling dir sharing the string prefix must NOT inherit the + // mount's fs type — it falls through to `/`. + assert_eq!(best_fs_type(&mounts, "/mnt/music2/x.flac"), Some("ext4")); + } + + #[test] + fn best_fs_type_longest_mount_wins() { + let mounts = vec![ + ("/".to_string(), "ext4".to_string()), + ("/mnt".to_string(), "xfs".to_string()), + ("/mnt/nas".to_string(), "cifs".to_string()), + ]; + assert_eq!(best_fs_type(&mounts, "/mnt/nas/music"), Some("cifs")); + assert_eq!(best_fs_type(&mounts, "/mnt/local"), Some("xfs")); + assert_eq!(best_fs_type(&mounts, "/home/user"), Some("ext4")); + } + #[test] fn parse_mounts_reads_typical_entries() { let sample = "\ diff --git a/crates/qbz-library/src/scan.rs b/crates/qbz-library/src/scan.rs index 811dcd033..8bf35bf7a 100644 --- a/crates/qbz-library/src/scan.rs +++ b/crates/qbz-library/src/scan.rs @@ -316,30 +316,49 @@ pub fn scan_with_progress( // Cleanup: remove tracks whose files no longer exist. Full scan checks the // whole DB; single-folder scan only the scanned folders' subtrees. + // + // GUARD: a network folder whose mount is currently DOWN must not have its + // subtree treated as missing — while unmounted every stat fails, and + // deleting the rows would wipe a whole folder's index over a transient + // condition (e.g. a reboot where the share didn't auto-mount). Those + // subtrees are skipped; they rehabilitate on the next scan after remount. on_event(ScanEvent::Cleanup); + let folder_prefix = |path: &str| { + if path.ends_with('/') { + path.to_string() + } else { + format!("{}/", path) + } + }; + let unavailable_prefixes: Vec = db + .get_folders_with_metadata() + .map(|folders| { + folders + .iter() + .filter(|f| f.is_network && std::fs::read_dir(&f.path).is_err()) + .map(|f| folder_prefix(&f.path)) + .collect() + }) + .unwrap_or_default(); + let under_unavailable = + |p: &str| unavailable_prefixes.iter().any(|pre| p.starts_with(pre.as_str())); if let Ok(tracks) = db.get_all_track_paths() { let missing: Vec = if single { - let prefixes: Vec = targets - .iter() - .map(|f| { - if f.path.ends_with('/') { - f.path.clone() - } else { - format!("{}/", f.path) - } - }) - .collect(); + let prefixes: Vec = + targets.iter().map(|f| folder_prefix(&f.path)).collect(); tracks .iter() .filter(|(_, p)| { - prefixes.iter().any(|pre| p.starts_with(pre)) && !Path::new(p).exists() + prefixes.iter().any(|pre| p.starts_with(pre)) + && !under_unavailable(p) + && !Path::new(p).exists() }) .map(|(id, _)| *id) .collect() } else { tracks .iter() - .filter(|(_, p)| !Path::new(p).exists()) + .filter(|(_, p)| !under_unavailable(p) && !Path::new(p).exists()) .map(|(id, _)| *id) .collect() }; diff --git a/crates/qbz-slint/src/local_library.rs b/crates/qbz-slint/src/local_library.rs index d00c00021..f7b1b4317 100644 --- a/crates/qbz-slint/src/local_library.rs +++ b/crates/qbz-slint/src/local_library.rs @@ -459,6 +459,42 @@ fn plex_cache_db_path() -> Option { dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db")) } +// NETWORK-FOLDER VISIBILITY (owner verdict 2026-06-10, refined same day): +// hiding network-folder content is keyed on RAW CONNECTIVITY, never on the +// offline MODE. A logged-out session or induced offline with the link up +// says nothing about LAN mounts — content stays visible there (offline mode +// exists precisely to use the local library). Only a CONFIRMED-down link +// (hard offline: no default route / probes dead) hides network folders, the +// one state where LAN mounts are gone too. An unmounted-while-online path is +// handled at PLAYBACK time instead (existence guard + friendly toast in +// playback.rs), not by hiding library content. +// +// Known approximation, accepted with the model: an ISP outage with a live +// LAN reads as hard offline and hides NAS content; per-mount accessibility +// checks in every browse query would be the exact-but-costly alternative. + +/// True only under HARD offline (connectivity confirmed down). See the +/// NETWORK-FOLDER VISIBILITY note above. +pub(crate) fn exclude_network_folders_now() -> bool { + crate::offline_mode::engine().status().connectivity + == qbz_app::offline_mode::Connectivity::Down +} + +/// Reset the four browse-tab models so each tab re-fetches on its next visit +/// (the `ensure_*_loaded` guards key on an empty model). Used after a scan, +/// after the danger-zone clear, and on offline-mode flips (where the +/// connectivity-keyed network-folder gate may change the browse SET — see +/// the NETWORK-FOLDER VISIBILITY note above). +pub fn reset_browse_models(window: &AppWindow) { + let s = window.global::(); + let empty_albums = ModelRc::new(VecModel::from(Vec::::new())); + let empty_tracks = ModelRc::new(VecModel::from(Vec::::new())); + s.set_albums(empty_albums.clone()); + s.set_folders(empty_albums); + s.set_tracks(empty_tracks); + s.set_artists(ModelRc::new(VecModel::from(Vec::::new()))); +} + /// Upper bound for the full-load page. The Albums tab loads the entire set in /// one shot (search/sort/filter/group are all derived client-side over the /// cached set in `derive_albums`), so we request a single large page rather @@ -485,6 +521,11 @@ fn spawn_albums_load( handle.spawn(async move { let loaded: Option<(Vec, Vec)> = tokio::task::spawn_blocking(move || { + // include_qobuz_downloads: true (offline copies belong in the + // grid — the toolbar's "Offline" source filter selects them). + // exclude_network_folders: connectivity-keyed — see the + // NETWORK-FOLDER VISIBILITY note. + let exclude_network = exclude_network_folders_now(); crate::library_db::with_db(|db| { let page = db.get_albums_metadata_page( 0, @@ -492,8 +533,8 @@ fn spawn_albums_load( None, "artist", "asc", - false, true, + exclude_network, plex_path.as_deref(), )?; let albums = page.albums; @@ -581,6 +622,10 @@ pub fn seed_counts(weak: slint::Weak, handle: tokio::runtime::Handle) let plex_enabled = crate::plex_settings::get().enabled; handle.spawn(async move { let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(move || { + // Same include-qobuz / network flags as the Albums tab loader, so + // the badge always matches the grid (network content always in — + // see the NETWORK-FOLDER VISIBILITY note). + let exclude_network = exclude_network_folders_now(); crate::library_db::with_db(|db| { // Total under the same filter the Albums tab uses, incl. the // Plex union when enabled, so the badge matches the grid. @@ -591,8 +636,8 @@ pub fn seed_counts(weak: slint::Weak, handle: tokio::runtime::Handle) None, "artist", "asc", - false, true, + exclude_network, plex_path.as_deref(), ) .map(|p| p.total as usize) @@ -759,8 +804,11 @@ fn fetch_tracks_page( offset: u64, plex: bool, ) -> Option<(Vec, bool)> { + // exclude_network_folders: connectivity-keyed — see the NETWORK-FOLDER + // VISIBILITY note. + let exclude_network = exclude_network_folders_now(); let mut rows = crate::library_db::with_db(|db| { - db.search_with_filter_page(query.trim(), offset, TRACKS_PAGE, true, false) + db.search_with_filter_page(query.trim(), offset, TRACKS_PAGE, true, exclude_network) })?; let has_more = rows.len() as u64 == TRACKS_PAGE; if plex && offset == 0 { @@ -1401,6 +1449,10 @@ fn spawn_plex_quality_hydration( // The album aggregate (MAX over tracks) is a pure runtime query; the // refreshed Plex track quality flows into it automatically. let album = plex_path.as_deref().and_then(|p| { + // Same flags as the Albums tab loader so the looked-up + // aggregate comes from the identical set (network content + // always in — see the NETWORK-FOLDER VISIBILITY note). + let exclude_network = exclude_network_folders_now(); crate::library_db::with_db(|db| { let page = db.get_albums_metadata_page( 0, @@ -1408,8 +1460,8 @@ fn spawn_plex_quality_hydration( None, "artist", "asc", - false, true, + exclude_network, Some(p), )?; Ok(page.albums.into_iter().find(|a| a.id == gk)) @@ -1631,11 +1683,15 @@ pub fn current_album_version_tracks(window: &AppWindow) -> Vec Vec { - crate::library_db::with_db(|db| { + // exclude_network_folders: connectivity-keyed — an unmounted-but-online + // folder stays visible (the index is the source); only hard offline + // hides it (see the NETWORK-FOLDER VISIBILITY note). + let exclude_network = exclude_network_folders_now(); + crate::library_db::with_db(move |db| { db.get_albums_with_full_filter( /* include_hidden */ false, /* include_qobuz_downloads */ true, - /* exclude_network_folders */ false, + exclude_network, ) }) .unwrap_or_default() @@ -2792,8 +2848,13 @@ pub fn ensure_artists_loaded( let plex_path = plex_cache_db_path(); handle.spawn(async move { let items = tokio::task::spawn_blocking(move || { - let artists = crate::library_db::with_db(|db| db.get_artists_with_filter(true, false)) - .unwrap_or_default(); + // Same network flag as every browse tab: connectivity-keyed + // — see the NETWORK-FOLDER VISIBILITY note. + let exclude_network = exclude_network_folders_now(); + let artists = crate::library_db::with_db(|db| { + db.get_artists_with_filter(true, exclude_network) + }) + .unwrap_or_default(); // Album cache for the right pane + album_count. With Plex ON, // use the Plex-aware ATTACH/union query so the artist-detail grid // and counts include Plex albums (1:1 with the Albums tab set); @@ -2806,8 +2867,8 @@ pub fn ensure_artists_loaded( None, "artist", "asc", - false, true, + exclude_network, plex_path.as_deref(), ) .map(|p| p.albums) @@ -2815,7 +2876,7 @@ pub fn ensure_artists_loaded( .unwrap_or_default() } else { crate::library_db::with_db(|db| { - db.get_albums_with_full_filter(false, true, false) + db.get_albums_with_full_filter(false, true, exclude_network) }) .unwrap_or_default() }; diff --git a/crates/qbz-slint/src/local_library_settings.rs b/crates/qbz-slint/src/local_library_settings.rs index dcd15866d..38c1a59c4 100644 --- a/crates/qbz-slint/src/local_library_settings.rs +++ b/crates/qbz-slint/src/local_library_settings.rs @@ -515,10 +515,31 @@ pub fn cleanup_missing(weak: Weak, handle: tokio::runtime::Handle) { handle.spawn(async move { let result = tokio::task::spawn_blocking(|| { let paths = crate::library_db::with_db(|db| db.get_all_track_paths())?; + // Same guard as the scan's cleanup phase: a network folder whose + // mount is DOWN right now stats as missing for every file — that + // is "unreachable", not "deleted". Skip those subtrees so a + // maintenance click while a share is unmounted can't wipe its + // index. + let skip: Vec = + crate::library_db::with_db(|db| db.get_folders_with_metadata()) + .unwrap_or_default() + .into_iter() + .filter(|f| f.is_network && std::fs::read_dir(&f.path).is_err()) + .map(|f| { + if f.path.ends_with('/') { + f.path + } else { + format!("{}/", f.path) + } + }) + .collect(); let checked = paths.len(); let missing: Vec = paths .into_iter() - .filter(|(_, p)| !std::path::Path::new(p).exists()) + .filter(|(_, p)| { + !skip.iter().any(|pre| p.starts_with(pre.as_str())) + && !std::path::Path::new(p).exists() + }) .map(|(id, _)| id) .collect(); let mut removed = 0usize; @@ -602,13 +623,7 @@ pub fn clear_library(weak: Weak, handle: tokio::runtime::Handle) { let _ = weak.upgrade_in_event_loop(|w| { w.global::().set_clearing_library(false); // Reset the browse models so the tabs re-fetch on next visit. - let s = w.global::(); - let empty_albums = ModelRc::new(VecModel::from(Vec::::new())); - let empty_tracks = ModelRc::new(VecModel::from(Vec::::new())); - s.set_albums(empty_albums.clone()); - s.set_folders(empty_albums); - s.set_tracks(empty_tracks); - s.set_artists(ModelRc::new(VecModel::from(Vec::::new()))); + crate::local_library::reset_browse_models(&w); }); if ok { crate::toast::success_weak(&weak, "Library database cleared"); @@ -752,13 +767,7 @@ fn run_scan(weak: Weak, handle: tokio::runtime::Handle, ids: Option(); - let empty_albums = ModelRc::new(VecModel::from(Vec::::new())); - let empty_tracks = ModelRc::new(VecModel::from(Vec::::new())); - s.set_albums(empty_albums.clone()); - s.set_folders(empty_albums); - s.set_tracks(empty_tracks); - s.set_artists(ModelRc::new(VecModel::from(Vec::::new()))); + crate::local_library::reset_browse_models(&w); }); load_folders(weak, h); }); diff --git a/crates/qbz-slint/src/myqbz_builder.rs b/crates/qbz-slint/src/myqbz_builder.rs index 7baa52aa4..ebac9a611 100644 --- a/crates/qbz-slint/src/myqbz_builder.rs +++ b/crates/qbz-slint/src/myqbz_builder.rs @@ -415,15 +415,20 @@ pub fn fetch_local_and_plex(artist_name: &str) -> Vec { // local rows carry `a.artwork_path`, with the same cover.jpg/folder.jpg // on-disk fallback the grid uses so a DB row missing artwork_path still // resolves a cover. - crate::library_db::with_db(|db| { + // Same flags as the LocalLibrary Albums tab (include offline copies; + // network content connectivity-keyed — see local_library.rs's + // NETWORK-FOLDER VISIBILITY note) so the builder sees the identical + // candidate set. + let exclude_network = crate::local_library::exclude_network_folders_now(); + crate::library_db::with_db(move |db| { let page = db.get_albums_metadata_page( 0, 1_000_000, None, "artist", "asc", - false, true, + exclude_network, plex_path.as_deref(), )?; let out: Vec = page diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index c5bd10951..df157888f 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -221,18 +221,60 @@ enum OfflinePlayability { /// The track IS offline-cached but the D4 subscription grace window has /// elapsed — gets its own honest message. GraceExpired, + /// A LOCAL user file whose indexed path is not on disk right now — + /// typically an unmounted network drive. Checked online AND offline + /// (library content is never hidden, so playback is where this must + /// surface) — gets the "is the drive mounted?" message. + FileMissing, +} + +/// Cheap existence guard for a LOCAL queue track's underlying file: resolve +/// the indexed path (ephemeral store, or one indexed library-DB read) and +/// stat it with `Path::exists()`. Unresolvable id/path → `true` (don't +/// invent a skip; the play path has its own not-found handling). +/// +/// D-STATE CAVEAT: `exists()` on an UNMOUNTED path returns false instantly +/// (the path simply isn't there) — that is the case this guards. But a stat +/// on a DEAD-yet-still-MOUNTED NFS/CIFS share can block in uninterruptible +/// sleep (D state). This is therefore only ever called from the async layer +/// (the advance walk and the play fast-fail run on the tokio runtime; +/// `play_local_file_audible` checks inside `spawn_blocking`) and NEVER from +/// the audio callback thread — a worst-case hang stalls an advance, not the +/// audio pipeline. Do NOT add mount probing here; the hot path stays one +/// stat per advance. +fn local_track_file_exists(track: &QueueTrack) -> bool { + let path = if crate::ephemeral::is_ephemeral_id(track.id as i64) { + crate::ephemeral::get_track(track.id as i64).map(|row| row.file_path) + } else { + crate::library_db::with_db(|db| db.get_track(track.id as i64)) + .flatten() + .map(|row| row.file_path) + }; + match path { + Some(p) => std::path::Path::new(&p).exists(), + None => true, + } } /// Decide whether `track` can play under the CURRENT offline status. -/// Online → always playable (the normal path pays one status read). +/// Local / ephemeral user files → existence-checked regardless of +/// online/offline (the library never hides network-folder content — see +/// local_library.rs's NETWORK-FOLDER VISIBILITY note — so an unmounted +/// drive is caught here, at playback time). +/// Online → otherwise always playable (the normal path pays one status read). /// Offline: -/// - local / ephemeral → playable (file presence is the player's own -/// failure path) /// - plex → induced offline only (a LAN Plex server may be reachable; /// under real offline it is not — Tauri parity) /// - qobuz (incl. "qobuz_download" copies, which keep the real Qobuz id) /// → offline-cached AND within the D4 subscription grace window fn offline_playability(track: &QueueTrack) -> OfflinePlayability { + if matches!(track.source.as_deref(), Some("local") | Some("ephemeral")) { + return if local_track_file_exists(track) { + OfflinePlayability::Playable + } else { + OfflinePlayability::FileMissing + }; + } let status = crate::offline_mode::engine().status(); if !status.is_offline() { return OfflinePlayability::Playable; @@ -241,7 +283,7 @@ fn offline_playability(track: &QueueTrack) -> OfflinePlayability { return OfflinePlayability::Playable; } match track.source.as_deref() { - Some("local") | Some("ephemeral") => OfflinePlayability::Playable, + // ("local" / "ephemeral" never reach here — handled above.) Some("plex") => { if status.mode == qbz_app::offline_mode::OfflineMode::InducedOffline { OfflinePlayability::Playable @@ -266,12 +308,14 @@ fn offline_track_playable(track: &QueueTrack) -> bool { offline_playability(track) == OfflinePlayability::Playable } -/// Move the queue cursor forward/backward to the next offline-playable -/// track. Online this returns the immediate neighbor on the first -/// iteration. Offline it skips unavailable tracks, bounded at -/// [`MAX_OFFLINE_SKIPS`] consecutive (Tauri #467 parity); on exhaustion -/// (bound hit, or queue edge after at least one skip) playback stops and -/// ONE toast reports it. +/// Move the queue cursor forward/backward to the next playable track. +/// Online this returns the immediate neighbor on the first iteration unless +/// that neighbor is a LOCAL file whose path is gone (unmounted drive) — the +/// only possible online skip. Offline it also skips unavailable tracks. +/// Bounded at [`MAX_OFFLINE_SKIPS`] consecutive (Tauri #467 parity); on +/// exhaustion (bound hit, or queue edge after at least one skip) playback +/// stops and ONE toast reports it — worded for the drive when every skip was +/// a missing local file, for offline otherwise. /// /// The gapless-prefetched target never passes through here: a gapless /// hand-off happens inside the audio engine and surfaces to the poll loop @@ -283,6 +327,17 @@ async fn advance_to_playable( forward: bool, ) -> Option { let mut skips = 0usize; + let mut missing_files = 0usize; + // One message for the whole walk: when every skipped track was a local + // file that isn't on disk, point at the drive; any other mix keeps the + // offline wording (online, FileMissing is the only possible skip). + let walk_toast = |skips: usize, missing_files: usize| { + if missing_files == skips { + "Files not available — is the drive mounted?" + } else { + "No tracks available offline" + } + }; loop { let step = if forward { runtime.core().next_track().await @@ -295,27 +350,29 @@ async fn advance_to_playable( if skips > 0 { crate::toast::show_weak( weak, - "No tracks available offline", + walk_toast(skips, missing_files), crate::ToastKind::Warning, ); } return None; }; - if offline_track_playable(&track) { - return Some(track); + match offline_playability(&track) { + OfflinePlayability::Playable => return Some(track), + OfflinePlayability::FileMissing => missing_files += 1, + _ => {} } skips += 1; log::info!( - "[qbz-slint] offline: skipping unavailable track {} ({skips}/{MAX_OFFLINE_SKIPS})", + "[qbz-slint] advance: skipping unavailable track {} ({skips}/{MAX_OFFLINE_SKIPS})", track.id ); if skips >= MAX_OFFLINE_SKIPS { if let Err(e) = runtime.core().stop() { - log::warn!("[qbz-slint] offline: stop after skip bound failed: {e}"); + log::warn!("[qbz-slint] advance: stop after skip bound failed: {e}"); } crate::toast::show_weak( weak, - "No tracks available offline", + walk_toast(skips, missing_files), crate::ToastKind::Warning, ); return None; @@ -359,6 +416,17 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id ); return; } + OfflinePlayability::FileMissing => { + log::info!( + "[qbz-slint] local play: refused track {track_id} (file missing — unmounted drive?)" + ); + crate::toast::show_weak( + weak, + "File not available — is the drive mounted?", + crate::ToastKind::Warning, + ); + return; + } } } } @@ -482,13 +550,30 @@ async fn play_local_file_audible( clear_loading(weak, row_id); return; }; + // PLAYBACK LOCK (owner verdict 2026-06-10): the library never hides + // network-folder content, so an unmounted drive surfaces HERE — one cheap + // `Path::exists()` stat before the read, with friendly feedback instead + // of a silent log-only failure. Runs inside spawn_blocking, never on the + // audio callback thread: an unmounted path returns false instantly; only + // a dead-but-still-mounted NFS/CIFS share could block, and then it blocks + // a pool thread, not audio (see `local_track_file_exists`). let read_path = path.clone(); - let bytes = tokio::task::spawn_blocking(move || std::fs::read(&read_path)) - .await - .ok() - .and_then(Result::ok); + let bytes = tokio::task::spawn_blocking(move || { + if !std::path::Path::new(&read_path).exists() { + return None; + } + std::fs::read(&read_path).ok() + }) + .await + .ok() + .flatten(); let Some(bytes) = bytes else { - log::error!("[qbz-slint] local play: failed to read {path}"); + log::error!("[qbz-slint] local play: file not available at {path}"); + crate::toast::show_weak( + weak, + "File not available — is the drive mounted?", + crate::ToastKind::Warning, + ); clear_loading(weak, row_id); return; }; diff --git a/crates/qbz-slint/src/settings.rs b/crates/qbz-slint/src/settings.rs index 9369430d2..9421d6fd0 100644 --- a/crates/qbz-slint/src/settings.rs +++ b/crates/qbz-slint/src/settings.rs @@ -754,19 +754,12 @@ pub async fn handle_bool( value: bool, ) { let key = key.as_str(); - // Offline-MODE toggles persist through the shared engine's per-user + // The offline-MODE toggle persists through the shared engine's per-user // store, not the audio/playback stores — routed apart from the Apply // machinery below. - match key { - "offline-mode-enabled" => { - set_offline_mode(ctx, runtime, weak, value).await; - return; - } - "offline-show-network-folders" => { - set_offline_network_folders(weak, value); - return; - } - _ => {} + if key == "offline-mode-enabled" { + set_offline_mode(ctx, runtime, weak, value).await; + return; } // Cross-setting cascades — force dependent settings off and persist // those forced changes. `cascaded` flags whether a full snapshot @@ -944,25 +937,6 @@ async fn set_offline_mode( } } -/// Settings > Offline — the Show Network Folder Content policy toggle -/// (visible only while induced offline is on). Pure persistence; the -/// gating slice consumes the flag. -fn set_offline_network_folders(weak: slint::Weak, value: bool) { - let engine = crate::offline_mode::engine(); - if let Err(e) = engine.set_show_network_folders(value) { - log::error!("[qbz-slint] show-network-folders toggle failed: {e}"); - // Revert the optimistic toggle to the persisted state. - let actual = engine - .settings() - .map(|s| s.show_network_folders_in_manual_offline) - .unwrap_or(!value); - let _ = weak.upgrade_in_event_loop(move |w| { - w.global::() - .set_offline_show_network_folders(actual); - }); - } -} - /// Handle a slider change: persist it and reload the player settings. /// Currently only the Initial Buffer Size slider exists. pub fn handle_slider( diff --git a/crates/qbz-slint/ui/settings/OfflineSettings.slint b/crates/qbz-slint/ui/settings/OfflineSettings.slint index 790802c77..98b2e90f7 100644 --- a/crates/qbz-slint/ui/settings/OfflineSettings.slint +++ b/crates/qbz-slint/ui/settings/OfflineSettings.slint @@ -119,19 +119,6 @@ export component OfflineSettings inherits VerticalLayout { } } } - // The network-folders policy only applies while induced offline is on, - // so the row only mounts then (Tauri parity; ADR-010 conditional mount). - if SettingsState.offline-mode-enabled: SettingRow { - label: @tr("Show Network Folder Content"); - description: @tr("Display content from network-mounted folders (NAS, Samba, etc.) while in offline mode."); - QbzToggle { - checked: SettingsState.offline-show-network-folders; - toggled(v) => { - SettingsState.offline-show-network-folders = v; - root.settings-bool("offline-show-network-folders", v); - } - } - } Rectangle { height: 12px; } Divider { }