feat: an unreachable track can no longer stop playback

Two owner requirements, one shape: something that cannot play must be
skipped, and cleaned when there is nowhere left to skip to. The causes
are outside our control — Qobuz withdraws a track overnight, or a
local/Plex file sits on a share this network cannot see today.

THE FUNNEL, which is where the real gap was. `play_current_if_local`
returned a bool whose two `false`s meant opposite things: "not my row,
run the Qobuz path" and "my row, and it cannot play". The caller could
only act on the first, so an unreachable local file fell through to the
Qobuz tier-walk, failed there with an unrelated error, and
`auto_skip_unavailable` then declined it as non-terminal. One dead file
stopped playback silently. It is now a three-state `LocalPlay`, and a
local failure takes the SAME bounded walk a pulled Qobuz track takes —
to the user they are the same event.

CLEANING. Both dead ends of that walk (budget spent, queue edge) now
call `purge_unavailable_upcoming`, which drops only rows
`queue_track_unavailable` already refuses to play, walking backwards so
a removal cannot shift an index still to be visited. A track on an
unreachable share can never land there: it is Unreachable, not dead.

PROBES THAT CANNOT HANG (`qbz-library/src/reachability.rs`, 4 tests).
The machinery for network shares already existed — mount_info,
`library_folders.is_network`, a populated `local_tracks.is_network_mount`,
even an unreachable-folder guard in scan.rs. Every one of them probed
with a bare blocking syscall, and a mounted-but-unreachable share does
not make `exists()`/`read_dir()` fail, it makes them BLOCK for the
mount's timeout. The guards could not fire; scan.rs hung on the very
condition it existed to detect.

Three states, not two, and that distinction is the whole safety
property: `Missing` is the filesystem saying the file is gone and may
be cleaned; `Unreachable` is it saying nothing, and may only ever be
skipped. Collapsing them — which a bare `exists()` does — would delete
a user's NAS library because they changed networks.

The deadline goes on the PROBE only, never the transfer: a hi-res FLAC
over a working-but-slow share legitimately takes longer than any probe
budget, and timing the read out would turn "your network is slow" into
"this track is gone". A blocked syscall cannot be cancelled, so a
timed-out probe leaks its thread; the mount is therefore remembered
with a cool-down, one probe per dead mount rather than one per track,
which on a 2000-row queue is the difference between a defence and a
thread bomb.

PERSISTENCE. `busy_timeout=1000` on all 18 store pragmas. Deliberately
NOT the 2-3s discussed: these writes run on the Qt thread, so a 3s wait
would install exactly the stall this whole sweep has been removing,
while today's 0 means contention loses a setting silently and instantly.
One second resolves transient contention without a visible stall.

`home_bridge::ui` announces both of its silent failure modes. Three
builds went into proving it was NOT dropping the Home document during
the launch freeze; one warn line would have answered it in one.

Verified against the owner's real session state (1935-row queue): the
app launches to a painted Discover with no QML errors and no dropped
publishes.

cc
This commit is contained in:
vicrodh committed 2026-08-18 12:40:52 -06:00
1 parent 47a7641fc2
commit 99722aedaf
25 files changed
+408 -45

No files matched your search

+39
View File
@@ -650,6 +650,45 @@ pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_ind
publish(runtime).await;
}
/// Drop every UPCOMING row that cannot play, and report how many went.
///
/// The last half of "an unavailable track is never blocking": it is skipped,
/// and when there is nowhere left to skip TO, the dead rows are cleaned out
/// rather than left for the user to walk into one at a time. Called only from
/// the dead ends of the skip walk — the budget running out, or the queue edge
/// being reached — never speculatively.
///
/// Walks BACKWARDS so each removal cannot shift an index still to be visited.
///
/// It removes what `queue_track_unavailable` already refuses to play, so this
/// can only delete rows that were unplayable anyway — never a track that is
/// merely on a share this network cannot see, because an unreachable local row
/// is `Unreachable`, not dead, and never enters that predicate.
pub async fn purge_unavailable_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> usize {
let doomed: Vec<usize> = {
let state = runtime.core().get_queue_state_full().await;
state
.upcoming
.iter()
.enumerate()
.filter(|(_, t)| crate::playback_qt::queue_track_unavailable(t))
.map(|(i, _)| i)
.collect()
};
if doomed.is_empty() {
return 0;
}
for index in doomed.iter().rev() {
runtime.core().remove_upcoming_track(*index).await;
}
log::info!(
"[qbz-qt] queue: purged {} unplayable upcoming row(s)",
doomed.len()
);
publish(runtime).await;
doomed.len()
}
pub async fn remove_all_after(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else {
log::warn!("[qbz-qt] queue: remove_all_after {page_index} out of range");