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
.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<String> {
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<String> {
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 = "\
+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
// 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<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() {
let missing: Vec<i64> = if single {
let prefixes: Vec<String> = targets
.iter()
.map(|f| {
if f.path.ends_with('/') {
f.path.clone()
} else {
format!("{}/", f.path)
}
})
.collect();
let prefixes: Vec<String> =
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()
};
+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"))
}
// 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
/// 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<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> =
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<AppWindow>, 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<AppWindow>, 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<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| {
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<qbz_library::Loca
// the Tracks table). Reuses AlbumCollectionView; covers load per card.
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(
/* 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()
};
+24 -15
View File
@@ -515,10 +515,31 @@ pub fn cleanup_missing(weak: Weak<AppWindow>, 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<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 missing: Vec<i64> = 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<AppWindow>, handle: tokio::runtime::Handle) {
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LibraryFoldersState>().set_clearing_library(false);
// Reset the browse models so the tabs re-fetch on next visit.
let s = w.global::<crate::LocalLibraryState>();
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())));
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<AppWindow>, handle: tokio::runtime::Handle, ids: Option<V
// Post-scan: refresh the folder list (last_scan labels) + reset the
// browse models so the tabs re-fetch the new index on next visit.
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<crate::LocalLibraryState>();
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())));
crate::local_library::reset_browse_models(&w);
});
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
// 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<Candidate> = page
+106 -21
View File
@@ -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<QueueTrack> {
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<AppWindow>, 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;
};
+4 -30
View File
@@ -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<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.
/// Currently only the Initial Buffer Size slider exists.
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; }
Divider { }