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
This commit is contained in:
vicrodh committed 2026-06-10 01:32:29 -06:00
1 parent 210b8020d3
commit 77dfd73b6d
8 files changed
+317 -130

No files matched your search

+74 -27
View File
@@ -56,22 +56,9 @@ pub fn is_network_path(path: &Path) -> bool {
let target = path let target = path
.canonicalize() .canonicalize()
.unwrap_or_else(|_| path.to_path_buf()); .unwrap_or_else(|_| path.to_path_buf());
let target_str = target.to_string_lossy();
// Longest-prefix match wins — `/` is always present but any match best_fs_type(&mounts, &target.to_string_lossy()) {
// deeper mount shadows it. Some(fs_type) => is_network_fs(fs_type),
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),
None => false, None => false,
} }
} }
@@ -96,24 +83,48 @@ pub fn network_fs_label(path: &Path) -> Option<String> {
let target = path let target = path
.canonicalize() .canonicalize()
.unwrap_or_else(|_| path.to_path_buf()); .unwrap_or_else(|_| path.to_path_buf());
let target_str = target.to_string_lossy();
let mut best: Option<(&str, usize)> = None; let fs_type = best_fs_type(&mounts, &target.to_string_lossy())?;
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)?;
if !is_network_fs(fs_type) { if !is_network_fs(fs_type) {
return None; return None;
} }
Some(normalize_network_label(fs_type)) 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"))] #[cfg(not(target_os = "linux"))]
pub fn network_fs_label(_path: &Path) -> Option<String> { pub fn network_fs_label(_path: &Path) -> Option<String> {
None None
@@ -180,9 +191,20 @@ fn parse_mounts(contents: &str) -> Vec<(String, String)> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn is_network_fs(fs_type: &str) -> bool { 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 NETWORK_FS_PREFIXES
.iter() .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"))] #[cfg(all(test, target_os = "linux"))]
@@ -207,6 +229,31 @@ mod tests {
assert!(!is_network_fs("fuse.gocryptfs")); 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] #[test]
fn parse_mounts_reads_typical_entries() { fn parse_mounts_reads_typical_entries() {
let sample = "\ let sample = "\
+31 -12
View File
@@ -316,30 +316,49 @@ pub fn scan_with_progress(
// Cleanup: remove tracks whose files no longer exist. Full scan checks the // Cleanup: remove tracks whose files no longer exist. Full scan checks the
// whole DB; single-folder scan only the scanned folders' subtrees. // 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); on_event(ScanEvent::Cleanup);
let folder_prefix = |path: &str| {
if path.ends_with('/') {
path.to_string()
} else {
format!("{}/", path)
}
};
let unavailable_prefixes: Vec<String> = 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() { if let Ok(tracks) = db.get_all_track_paths() {
let missing: Vec<i64> = if single { let missing: Vec<i64> = if single {
let prefixes: Vec<String> = targets let prefixes: Vec<String> =
.iter() targets.iter().map(|f| folder_prefix(&f.path)).collect();
.map(|f| {
if f.path.ends_with('/') {
f.path.clone()
} else {
format!("{}/", f.path)
}
})
.collect();
tracks tracks
.iter() .iter()
.filter(|(_, p)| { .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) .map(|(id, _)| *id)
.collect() .collect()
} else { } else {
tracks tracks
.iter() .iter()
.filter(|(_, p)| !Path::new(p).exists()) .filter(|(_, p)| !under_unavailable(p) && !Path::new(p).exists())
.map(|(id, _)| *id) .map(|(id, _)| *id)
.collect() .collect()
}; };
+71 -10
View File
@@ -459,6 +459,42 @@ fn plex_cache_db_path() -> Option<std::path::PathBuf> {
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db")) 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::<LocalLibraryState>();
let empty_albums = ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new()));
let empty_tracks = ModelRc::new(VecModel::from(Vec::<TrackItem>::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::<LocalArtistItem>::new())));
}
/// Upper bound for the full-load page. The Albums tab loads the entire set in /// 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 /// 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 /// 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 { handle.spawn(async move {
let loaded: Option<(Vec<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> = let loaded: Option<(Vec<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> =
tokio::task::spawn_blocking(move || { 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| { crate::library_db::with_db(|db| {
let page = db.get_albums_metadata_page( let page = db.get_albums_metadata_page(
0, 0,
@@ -492,8 +533,8 @@ fn spawn_albums_load(
None, None,
"artist", "artist",
"asc", "asc",
false,
true, true,
exclude_network,
plex_path.as_deref(), plex_path.as_deref(),
)?; )?;
let albums = page.albums; let albums = page.albums;
@@ -581,6 +622,10 @@ pub fn seed_counts(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle)
let plex_enabled = crate::plex_settings::get().enabled; let plex_enabled = crate::plex_settings::get().enabled;
handle.spawn(async move { handle.spawn(async move {
let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(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| { crate::library_db::with_db(|db| {
// Total under the same filter the Albums tab uses, incl. the // Total under the same filter the Albums tab uses, incl. the
// Plex union when enabled, so the badge matches the grid. // Plex union when enabled, so the badge matches the grid.
@@ -591,8 +636,8 @@ pub fn seed_counts(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle)
None, None,
"artist", "artist",
"asc", "asc",
false,
true, true,
exclude_network,
plex_path.as_deref(), plex_path.as_deref(),
) )
.map(|p| p.total as usize) .map(|p| p.total as usize)
@@ -759,8 +804,11 @@ fn fetch_tracks_page(
offset: u64, offset: u64,
plex: bool, plex: bool,
) -> Option<(Vec<qbz_library::LocalTrack>, bool)> { ) -> Option<(Vec<qbz_library::LocalTrack>, 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| { 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; let has_more = rows.len() as u64 == TRACKS_PAGE;
if plex && offset == 0 { 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 // The album aggregate (MAX over tracks) is a pure runtime query; the
// refreshed Plex track quality flows into it automatically. // refreshed Plex track quality flows into it automatically.
let album = plex_path.as_deref().and_then(|p| { 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| { crate::library_db::with_db(|db| {
let page = db.get_albums_metadata_page( let page = db.get_albums_metadata_page(
0, 0,
@@ -1408,8 +1460,8 @@ fn spawn_plex_quality_hydration(
None, None,
"artist", "artist",
"asc", "asc",
false,
true, true,
exclude_network,
Some(p), Some(p),
)?; )?;
Ok(page.albums.into_iter().find(|a| a.id == gk)) Ok(page.albums.into_iter().find(|a| a.id == gk))
@@ -1631,11 +1683,15 @@ pub fn current_album_version_tracks(window: &AppWindow) -> Vec<qbz_library::Loca
// the Tracks table). Reuses AlbumCollectionView; covers load per card. // the Tracks table). Reuses AlbumCollectionView; covers load per card.
fn fetch_folder_albums() -> Vec<crate::album_map::AlbumCard> { fn fetch_folder_albums() -> Vec<crate::album_map::AlbumCard> {
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( db.get_albums_with_full_filter(
/* include_hidden */ false, /* include_hidden */ false,
/* include_qobuz_downloads */ true, /* include_qobuz_downloads */ true,
/* exclude_network_folders */ false, exclude_network,
) )
}) })
.unwrap_or_default() .unwrap_or_default()
@@ -2792,8 +2848,13 @@ pub fn ensure_artists_loaded(
let plex_path = plex_cache_db_path(); let plex_path = plex_cache_db_path();
handle.spawn(async move { handle.spawn(async move {
let items = tokio::task::spawn_blocking(move || { let items = tokio::task::spawn_blocking(move || {
let artists = crate::library_db::with_db(|db| db.get_artists_with_filter(true, false)) // Same network flag as every browse tab: connectivity-keyed
.unwrap_or_default(); // — 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, // Album cache for the right pane + album_count. With Plex ON,
// use the Plex-aware ATTACH/union query so the artist-detail grid // use the Plex-aware ATTACH/union query so the artist-detail grid
// and counts include Plex albums (1:1 with the Albums tab set); // and counts include Plex albums (1:1 with the Albums tab set);
@@ -2806,8 +2867,8 @@ pub fn ensure_artists_loaded(
None, None,
"artist", "artist",
"asc", "asc",
false,
true, true,
exclude_network,
plex_path.as_deref(), plex_path.as_deref(),
) )
.map(|p| p.albums) .map(|p| p.albums)
@@ -2815,7 +2876,7 @@ pub fn ensure_artists_loaded(
.unwrap_or_default() .unwrap_or_default()
} else { } else {
crate::library_db::with_db(|db| { 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() .unwrap_or_default()
}; };
+24 -15
View File
@@ -515,10 +515,31 @@ pub fn cleanup_missing(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
handle.spawn(async move { handle.spawn(async move {
let result = tokio::task::spawn_blocking(|| { let result = tokio::task::spawn_blocking(|| {
let paths = crate::library_db::with_db(|db| db.get_all_track_paths())?; 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<String> =
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 checked = paths.len();
let missing: Vec<i64> = paths let missing: Vec<i64> = paths
.into_iter() .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) .map(|(id, _)| id)
.collect(); .collect();
let mut removed = 0usize; let mut removed = 0usize;
@@ -602,13 +623,7 @@ pub fn clear_library(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let _ = weak.upgrade_in_event_loop(|w| { let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LibraryFoldersState>().set_clearing_library(false); w.global::<LibraryFoldersState>().set_clearing_library(false);
// Reset the browse models so the tabs re-fetch on next visit. // Reset the browse models so the tabs re-fetch on next visit.
let s = w.global::<crate::LocalLibraryState>(); crate::local_library::reset_browse_models(&w);
let empty_albums = ModelRc::new(VecModel::from(Vec::<crate::AlbumCardItem>::new()));
let empty_tracks = ModelRc::new(VecModel::from(Vec::<crate::TrackItem>::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::<crate::LocalArtistItem>::new())));
}); });
if ok { if ok {
crate::toast::success_weak(&weak, "Library database cleared"); crate::toast::success_weak(&weak, "Library database cleared");
@@ -752,13 +767,7 @@ fn run_scan(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, ids: Option<V
// Post-scan: refresh the folder list (last_scan labels) + reset the // Post-scan: refresh the folder list (last_scan labels) + reset the
// browse models so the tabs re-fetch the new index on next visit. // browse models so the tabs re-fetch the new index on next visit.
let _ = weak.upgrade_in_event_loop(|w| { let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<crate::LocalLibraryState>(); crate::local_library::reset_browse_models(&w);
let empty_albums = ModelRc::new(VecModel::from(Vec::<crate::AlbumCardItem>::new()));
let empty_tracks = ModelRc::new(VecModel::from(Vec::<crate::TrackItem>::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::<crate::LocalArtistItem>::new())));
}); });
load_folders(weak, h); load_folders(weak, h);
}); });
+7 -2
View File
@@ -415,15 +415,20 @@ pub fn fetch_local_and_plex(artist_name: &str) -> Vec<Candidate> {
// local rows carry `a.artwork_path`, with the same cover.jpg/folder.jpg // 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 // on-disk fallback the grid uses so a DB row missing artwork_path still
// resolves a cover. // 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( let page = db.get_albums_metadata_page(
0, 0,
1_000_000, 1_000_000,
None, None,
"artist", "artist",
"asc", "asc",
false,
true, true,
exclude_network,
plex_path.as_deref(), plex_path.as_deref(),
)?; )?;
let out: Vec<Candidate> = page let out: Vec<Candidate> = page
+106 -21
View File
@@ -221,18 +221,60 @@ enum OfflinePlayability {
/// The track IS offline-cached but the D4 subscription grace window has /// The track IS offline-cached but the D4 subscription grace window has
/// elapsed — gets its own honest message. /// elapsed — gets its own honest message.
GraceExpired, 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. /// 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: /// Offline:
/// - local / ephemeral → playable (file presence is the player's own
/// failure path)
/// - plex → induced offline only (a LAN Plex server may be reachable; /// - plex → induced offline only (a LAN Plex server may be reachable;
/// under real offline it is not — Tauri parity) /// under real offline it is not — Tauri parity)
/// - qobuz (incl. "qobuz_download" copies, which keep the real Qobuz id) /// - qobuz (incl. "qobuz_download" copies, which keep the real Qobuz id)
/// → offline-cached AND within the D4 subscription grace window /// → offline-cached AND within the D4 subscription grace window
fn offline_playability(track: &QueueTrack) -> OfflinePlayability { 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(); let status = crate::offline_mode::engine().status();
if !status.is_offline() { if !status.is_offline() {
return OfflinePlayability::Playable; return OfflinePlayability::Playable;
@@ -241,7 +283,7 @@ fn offline_playability(track: &QueueTrack) -> OfflinePlayability {
return OfflinePlayability::Playable; return OfflinePlayability::Playable;
} }
match track.source.as_deref() { match track.source.as_deref() {
Some("local") | Some("ephemeral") => OfflinePlayability::Playable, // ("local" / "ephemeral" never reach here — handled above.)
Some("plex") => { Some("plex") => {
if status.mode == qbz_app::offline_mode::OfflineMode::InducedOffline { if status.mode == qbz_app::offline_mode::OfflineMode::InducedOffline {
OfflinePlayability::Playable OfflinePlayability::Playable
@@ -266,12 +308,14 @@ fn offline_track_playable(track: &QueueTrack) -> bool {
offline_playability(track) == OfflinePlayability::Playable offline_playability(track) == OfflinePlayability::Playable
} }
/// Move the queue cursor forward/backward to the next offline-playable /// Move the queue cursor forward/backward to the next playable track.
/// track. Online this returns the immediate neighbor on the first /// Online this returns the immediate neighbor on the first iteration unless
/// iteration. Offline it skips unavailable tracks, bounded at /// that neighbor is a LOCAL file whose path is gone (unmounted drive) — the
/// [`MAX_OFFLINE_SKIPS`] consecutive (Tauri #467 parity); on exhaustion /// only possible online skip. Offline it also skips unavailable tracks.
/// (bound hit, or queue edge after at least one skip) playback stops and /// Bounded at [`MAX_OFFLINE_SKIPS`] consecutive (Tauri #467 parity); on
/// ONE toast reports it. /// 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 /// The gapless-prefetched target never passes through here: a gapless
/// hand-off happens inside the audio engine and surfaces to the poll loop /// hand-off happens inside the audio engine and surfaces to the poll loop
@@ -283,6 +327,17 @@ async fn advance_to_playable(
forward: bool, forward: bool,
) -> Option<QueueTrack> { ) -> Option<QueueTrack> {
let mut skips = 0usize; 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 { loop {
let step = if forward { let step = if forward {
runtime.core().next_track().await runtime.core().next_track().await
@@ -295,27 +350,29 @@ async fn advance_to_playable(
if skips > 0 { if skips > 0 {
crate::toast::show_weak( crate::toast::show_weak(
weak, weak,
"No tracks available offline", walk_toast(skips, missing_files),
crate::ToastKind::Warning, crate::ToastKind::Warning,
); );
} }
return None; return None;
}; };
if offline_track_playable(&track) { match offline_playability(&track) {
return Some(track); OfflinePlayability::Playable => return Some(track),
OfflinePlayability::FileMissing => missing_files += 1,
_ => {}
} }
skips += 1; skips += 1;
log::info!( log::info!(
"[qbz-slint] offline: skipping unavailable track {} ({skips}/{MAX_OFFLINE_SKIPS})", "[qbz-slint] advance: skipping unavailable track {} ({skips}/{MAX_OFFLINE_SKIPS})",
track.id track.id
); );
if skips >= MAX_OFFLINE_SKIPS { if skips >= MAX_OFFLINE_SKIPS {
if let Err(e) = runtime.core().stop() { 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( crate::toast::show_weak(
weak, weak,
"No tracks available offline", walk_toast(skips, missing_files),
crate::ToastKind::Warning, crate::ToastKind::Warning,
); );
return None; return None;
@@ -359,6 +416,17 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak<AppWindow>, track_id
); );
return; 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); clear_loading(weak, row_id);
return; 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 read_path = path.clone();
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&read_path)) let bytes = tokio::task::spawn_blocking(move || {
.await if !std::path::Path::new(&read_path).exists() {
.ok() return None;
.and_then(Result::ok); }
std::fs::read(&read_path).ok()
})
.await
.ok()
.flatten();
let Some(bytes) = bytes else { 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); clear_loading(weak, row_id);
return; return;
}; };
+4 -30
View File
@@ -754,19 +754,12 @@ pub async fn handle_bool(
value: bool, value: bool,
) { ) {
let key = key.as_str(); 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 // store, not the audio/playback stores — routed apart from the Apply
// machinery below. // machinery below.
match key { if key == "offline-mode-enabled" {
"offline-mode-enabled" => { set_offline_mode(ctx, runtime, weak, value).await;
set_offline_mode(ctx, runtime, weak, value).await; return;
return;
}
"offline-show-network-folders" => {
set_offline_network_folders(weak, value);
return;
}
_ => {}
} }
// Cross-setting cascades — force dependent settings off and persist // Cross-setting cascades — force dependent settings off and persist
// those forced changes. `cascaded` flags whether a full snapshot // 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<AppWindow>, 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::<SettingsState>()
.set_offline_show_network_folders(actual);
});
}
}
/// Handle a slider change: persist it and reload the player settings. /// Handle a slider change: persist it and reload the player settings.
/// Currently only the Initial Buffer Size slider exists. /// Currently only the Initial Buffer Size slider exists.
pub fn handle_slider( pub fn handle_slider(
@@ -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; } Rectangle { height: 12px; }
Divider { } Divider { }