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:
25 files changed
+408
-45
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user