diff --git a/crates/qbz-app/src/lib.rs b/crates/qbz-app/src/lib.rs index f7275996f..e4ce84a26 100644 --- a/crates/qbz-app/src/lib.rs +++ b/crates/qbz-app/src/lib.rs @@ -7,6 +7,7 @@ pub mod playback_driver; pub mod qconnect_identity; pub mod runtime; pub mod scrobble_timing; +pub mod session_persist; pub mod session_store; pub mod shell; pub mod settings; diff --git a/crates/qbz-app/src/session_persist.rs b/crates/qbz-app/src/session_persist.rs new file mode 100644 index 000000000..1b472c2d1 --- /dev/null +++ b/crates/qbz-app/src/session_persist.rs @@ -0,0 +1,400 @@ +//! Queue + active-track persistence — SHARED. +//! +//! Lifted out of `crates/qbz/src/session_persist.rs` (the Slint binary), where +//! the only thing tying it to Slint was one type alias: +//! `type Runtime = Arc>`. Everything it touches — +//! `session_store`, `settings::playback`, `shell::AppRuntime`, `qbz_models` — +//! already lives in shared crates, so the Qt port had no session persistence +//! for no structural reason at all. ADR-006. +//! +//! The crash-chain level is injected by the host rather than read from a +//! frontend-local static: both binaries keep their own probe file, and this +//! module only needs the NUMBER. +//! +//! DEBT: the Slint copy is still there, untouched on purpose — moving it forces +//! a rebuild of the Slint binary, which pulls `qbz-ui` and its ~30 GB compile +//! that this box cannot verify. Collapse the two the next time Slint is built. + +//! What it is: the glue for the `persist_session` / +//! `resume_playback_position` playback preferences. +//! +//! The on-disk store is the frontend-agnostic [`SessionStore`] in +//! `qbz-app` (a per-user `session.db`, already built + tested). This module is +//! the thin frontend glue: it owns a process-global store handle (bound at +//! session activation), captures the live queue + playback state into the +//! persisted snapshot at meaningful edges, and restores it at startup. +//! +//! Gating: `persist_session` (restore the queue/session) and +//! `resume_playback_position` (also restore the exact position) are per-user +//! playback preferences. Both are cached here so the hot save path never reopens +//! the prefs DB; [`set_gates`] refreshes the cache when the toggles change, and +//! [`init_for_user`] seeds them synchronously when the store opens (no race with +//! the async settings snapshot load). +//! +//! Restore is PAUSED: the queue + current track come back — it touches +//! NO protected-audio code beyond threading an existing `start_position_secs`. +//! The saved position rides along via [`take_resume_for`] and is consumed on the +//! first play of the restored track, reusing the player's session-resume offset. +//! Nothing here touches the protected audio path beyond threading an existing +//! `start_position_secs` argument. + + +/// Host-set crash-chain level (see the callers' probe). 1 = clean previous boot. +static CRASH_CHAIN: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(1); + +/// The frontend adapter bound to the runtime. Named locally so the signatures +/// below read as one bound instead of repeating the whole where-clause. +pub trait Adapter: qbz_models::traits::FrontendAdapter + Send + Sync + 'static {} +impl Adapter for T {} + +/// Publish this boot's crash-chain level. Call once, after arming the probe. +pub fn set_crash_chain_level(level: u8) { + CRASH_CHAIN.store(level, std::sync::atomic::Ordering::Relaxed); +} + +fn crash_chain_level() -> u8 { + CRASH_CHAIN.load(std::sync::atomic::Ordering::Relaxed) +} + + +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::session_store::{ + PersistedPlaybackSession, PersistedQueueTrack, PersistedSessionSnapshot, + PersistedShellViewState, SessionStore, +}; +use crate::settings::playback::PlaybackPreferencesStore; +use crate::shell::AppRuntime; +use qbz_models::{QueueTrack, RepeatMode}; + +/// Per-user session store, bound at activation (None before login / on failure). +static STORE: Mutex> = Mutex::new(None); +/// Cached `persist_session` gate — captured/restored only when true. +static PERSIST_SESSION: AtomicBool = AtomicBool::new(false); +/// Cached `resume_playback_position` gate — primes [`PENDING_RESUME`] at restore. +static RESUME_POSITION: AtomicBool = AtomicBool::new(false); +/// Position (secs) to resume the restored current track at on the first play. +/// 0 = none. Written at restore (when resume is on); consumed on first play. +static PENDING_RESUME: AtomicU64 = AtomicU64::new(0); +/// Track id the pending resume position applies to, so ONLY the restored current +/// track resumes — playing any other track first starts from 0. 0 = none. +static PENDING_RESUME_TRACK: AtomicU64 = AtomicU64::new(0); +/// Runtime + tokio handle captured at shell entry, so the synchronous window +/// close handlers can flush a final full snapshot before the loop quits. +/// Type-ERASED on purpose. A `static` cannot name `Arc>` now that +/// `A` is a parameter, so what gets stored is the finished job — a closure that +/// already owns the runtime and the handle — instead of the ingredients. +type ExitSaver = Box; +static EXIT_CTX: OnceLock = OnceLock::new(); + +/// Bind the runtime + tokio handle for [`save_on_exit`]. Called once at shell +/// entry (idempotent — later calls are ignored by `OnceLock`). +pub fn bind_exit_ctx(runtime: Arc>, handle: tokio::runtime::Handle) { + let _ = EXIT_CTX.set(Box::new(move || { + handle.block_on(capture_and_save(&runtime)); + })); +} + +/// Flush a final full snapshot synchronously (the window close handlers run on +/// the UI thread, off the tokio runtime, so we `block_on`). No-op until the exit +/// context is bound or unless `persist_session` is on. +pub fn save_on_exit() { + if !persist_enabled() { + return; + } + if let Some(save) = EXIT_CTX.get() { + save(); + } +} + +/// Open the per-user session store and seed the gate flags synchronously from +/// the playback preferences. Called at session activation alongside the other +/// `init_for_user` stores. Failures degrade to "no persistence" (logged). +pub fn init_for_user(base_dir: &Path) { + let opened = match SessionStore::new_at(base_dir) { + Ok(store) => { + *STORE.lock().unwrap() = Some(store); + true + } + Err(e) => { + log::warn!("[session] persist: open failed: {e}"); + *STORE.lock().unwrap() = None; + false + } + }; + // Seed the gates from the per-user playback prefs so capture/restore work + // before the async settings snapshot has had a chance to call set_gates. + match PlaybackPreferencesStore::new_at(base_dir).and_then(|s| s.get_preferences()) { + Ok(prefs) => set_gates(prefs.persist_session, prefs.resume_playback_position), + Err(e) => { + log::warn!("[session] persist: prefs read failed, gates off: {e}"); + set_gates(false, false); + } + } + log::info!( + "[session] persist: init at {} (store_open={opened}, persist={}, resume={})", + base_dir.display(), + PERSIST_SESSION.load(Ordering::Relaxed), + RESUME_POSITION.load(Ordering::Relaxed) + ); +} + +/// Refresh the cached gate flags (called by the Settings toggle handlers and the +/// settings snapshot load so the cache tracks live preference changes). +pub fn set_gates(persist_session: bool, resume_position: bool) { + PERSIST_SESSION.store(persist_session, Ordering::Relaxed); + RESUME_POSITION.store(resume_position, Ordering::Relaxed); +} + +/// Set ONE gate. The two toggles live on different Settings rows and each +/// handler only knows its own new value; making it read the sibling pref back +/// off disk just to call `set_gates` is a round trip that can also disagree +/// with what is on screen. +pub fn set_persist_gate(on: bool) { + PERSIST_SESSION.store(on, Ordering::Relaxed); +} + +/// Sibling of [`set_persist_gate`] for the resume-position row. +pub fn set_resume_gate(on: bool) { + RESUME_POSITION.store(on, Ordering::Relaxed); +} + +/// Whether session persistence is currently enabled. +pub fn persist_enabled() -> bool { + PERSIST_SESSION.load(Ordering::Relaxed) +} + +/// Take + clear the pending resume position IF it applies to `track_id` (the +/// restored current track). Returns the saved position once, then 0 forever — +/// and 0 for any other track, so playing something else first never resumes. +pub fn take_resume_for(track_id: u64) -> u64 { + if track_id != 0 && PENDING_RESUME_TRACK.swap(0, Ordering::Relaxed) == track_id { + PENDING_RESUME.swap(0, Ordering::Relaxed) + } else { + 0 + } +} + +/// Peek the pending resume position WITHOUT consuming it (so the seek bar can be +/// seeded at restore while the actual resume still fires on first play). 0 = none. +pub fn pending_resume_position() -> u64 { + if PENDING_RESUME_TRACK.load(Ordering::Relaxed) != 0 { + PENDING_RESUME.load(Ordering::Relaxed) + } else { + 0 + } +} + +fn repeat_to_str(mode: RepeatMode) -> &'static str { + match mode { + RepeatMode::Off => "off", + RepeatMode::All => "all", + RepeatMode::One => "one", + } +} + +fn repeat_from_str(s: &str) -> RepeatMode { + match s { + "all" => RepeatMode::All, + "one" => RepeatMode::One, + _ => RepeatMode::Off, + } +} + +fn to_persisted(t: &QueueTrack) -> PersistedQueueTrack { + PersistedQueueTrack { + id: t.id, + title: t.title.clone(), + artist: t.artist.clone(), + album: t.album.clone(), + duration_secs: t.duration_secs, + artwork_url: t.artwork_url.clone(), + hires: t.hires, + bit_depth: t.bit_depth, + sample_rate: t.sample_rate, + is_local: t.is_local, + album_id: t.album_id.clone(), + artist_id: t.artist_id, + streamable: t.streamable, + source: t.source.clone(), + parental_warning: t.parental_warning, + source_item_id_hint: t.source_item_id_hint.clone(), + } +} + +fn from_persisted(t: PersistedQueueTrack) -> QueueTrack { + QueueTrack { + id: t.id, + title: t.title, + // The persisted schema predates `version` (Tauri parity): the edition + // subtitle is not stored, so a restored track simply has no version. + version: None, + artist: t.artist, + album: t.album, + // Album-version is cosmetic (now-playing/MPRIS); not persisted in the + // session schema, so a restored track shows the clean album until the + // next album-play repopulates it. + album_version: None, + duration_secs: t.duration_secs, + artwork_url: t.artwork_url, + hires: t.hires, + bit_depth: t.bit_depth, + sample_rate: t.sample_rate, + is_local: t.is_local, + album_id: t.album_id, + artist_id: t.artist_id, + streamable: t.streamable, + source: t.source, + parental_warning: t.parental_warning, + source_item_id_hint: t.source_item_id_hint, + // Not persisted in the session schema — a restored track carries no + // container origin, so the "playing from" button falls back to the + // track's own album until the next container play re-stamps the queue. + context_kind: None, + context_id: None, + } +} + +/// Capture the live queue + playback state and persist it. No-op unless +/// `persist_session` is on and the store is open. Async (reads the queue lock). +pub async fn capture_and_save(runtime: &Arc>) { + if !persist_enabled() { + return; + } + let (tracks, current_index) = runtime.core().get_all_queue_tracks().await; + // Crash-chain level >=3 bypassed the restore this boot, so the queue on + // disk is the GOOD copy the user wants back on a healthy start — don't + // clobber it with this session's empty queue at exit. A queue the user + // actually built during the recovered boot still saves normally. + if tracks.is_empty() && crash_chain_level() >= 3 { + log::info!( + "[session] persist: crash-chain recovery boot with empty queue — \ + keeping the preserved snapshot on disk" + ); + return; + } + let full = runtime.core().get_queue_state_full().await; + let pb = runtime.core().get_playback_state(); + // POSITION: the engine reports 0 until something actually loads, so a + // session that was restored and then closed WITHOUT playing would write + // that idle 0 over the resume point the restore had just armed — one + // open-and-close and the user silently loses their place, with the queue + // still there to make it look like persistence worked. + // + // An UNCONSUMED pending resume is the truthful value in exactly that case. + // `pending_resume_position` self-clears the moment the first play takes it + // (`take_resume_for` zeroes the track id), so this can never resurrect a + // stale position: once the track has played, the engine's own number wins. + let position = if pb.position == 0 && !pb.is_playing { + let pending = pending_resume_position(); + if pending > 0 { + log::info!( + "[session] persist: keeping the un-consumed resume position {pending}s (engine idle at 0 — nothing was played this session)" + ); + } + pending + } else { + pb.position + }; + let snapshot = PersistedSessionSnapshot { + playback: PersistedPlaybackSession { + queue_tracks: tracks.iter().map(to_persisted).collect(), + current_index, + current_position_secs: position, + volume: pb.volume, + shuffle_enabled: full.shuffle, + repeat_mode: repeat_to_str(full.repeat).to_string(), + was_playing: pb.is_playing, + saved_at: 0, // set inside save_session + }, + // Shell-view restoration is handled separately (ui_prefs startup_page); + // keep the Tauri view columns at their defaults so the schema round-trips. + shell_view: PersistedShellViewState::default(), + }; + let track_count = snapshot.playback.queue_tracks.len(); + if let Some(store) = STORE.lock().unwrap().as_ref() { + match store.save_session(&snapshot) { + Ok(()) => log::info!( + "[session] persist: saved {track_count} queue tracks (pos {}s, playing {})", + snapshot.playback.current_position_secs, + snapshot.playback.was_playing + ), + Err(e) => log::warn!("[session] persist: save failed: {e}"), + } + } else { + log::warn!("[session] persist: capture skipped (store not open)"); + } +} + +/// Quick position-only save (a single cheap UPDATE) — for the poll loop's +/// throttled tick and the pause edge, so a crash keeps a near-current position. +pub fn save_position(position_secs: u64) { + if !persist_enabled() { + return; + } + if let Some(store) = STORE.lock().unwrap().as_ref() { + let _ = store.save_position(position_secs); + } +} + +/// Restore the persisted queue at startup. Returns true if a non-empty queue was +/// restored (so the caller refreshes the now-playing bar). Restores PAUSED; when +/// `resume_playback_position` is on, primes [`PENDING_RESUME`] for Phase B. +pub async fn restore(runtime: &Arc>) -> bool { + if !persist_enabled() { + log::info!("[session] persist: restore skipped (persist_session off)"); + return false; + } + let snapshot = { + let guard = STORE.lock().unwrap(); + let Some(store) = guard.as_ref() else { + log::warn!("[session] persist: restore skipped (store not open)"); + return false; + }; + match store.load_session() { + Ok(s) => s, + Err(e) => { + log::warn!("[session] persist: load failed: {e}"); + return false; + } + } + }; + let pb_sess = snapshot.playback; + if pb_sess.queue_tracks.is_empty() { + log::info!("[session] persist: nothing to restore (saved queue is empty)"); + return false; + } + let position = pb_sess.current_position_secs; + let count = pb_sess.queue_tracks.len(); + let index = pb_sess.current_index; + let tracks: Vec = pb_sess + .queue_tracks + .into_iter() + .map(from_persisted) + .collect(); + // The current track's id, so the resume position is applied ONLY when this + // exact track is the first one played after the restore. + let current_track_id = index.and_then(|i| tracks.get(i)).map(|t| t.id).unwrap_or(0); + runtime + .core() + .set_queue_with_order(tracks, index, pb_sess.shuffle_enabled, None) + .await; + runtime + .core() + .set_repeat_mode(repeat_from_str(&pb_sess.repeat_mode)) + .await; + // The queue session carries the authoritative last volume; apply it to the + // player (the slider also seeds from ui_prefs, but this keeps them in step). + let _ = runtime.core().set_volume(pb_sess.volume); + if RESUME_POSITION.load(Ordering::Relaxed) && position > 0 && current_track_id != 0 { + PENDING_RESUME.store(position, Ordering::Relaxed); + PENDING_RESUME_TRACK.store(current_track_id, Ordering::Relaxed); + } + log::info!( + "[session] persist: restored {count} queue tracks (index {index:?}), paused; \ + resume position {position}s (consumed on first play when enabled)" + ); + true +} diff --git a/crates/qbz-player/src/lib.rs b/crates/qbz-player/src/lib.rs index 1df624a78..b904fa269 100644 --- a/crates/qbz-player/src/lib.rs +++ b/crates/qbz-player/src/lib.rs @@ -32,6 +32,7 @@ //! ``` pub mod player; +pub mod remote_stream; pub mod queue; // Re-export main types diff --git a/crates/qbz-player/src/remote_stream.rs b/crates/qbz-player/src/remote_stream.rs new file mode 100644 index 000000000..f50095638 --- /dev/null +++ b/crates/qbz-player/src/remote_stream.rs @@ -0,0 +1,335 @@ +//! Progressive Range-streaming feeder — SHARED. +//! +//! Lifted verbatim out of `crates/qbz/src/remote_stream.rs` (the Slint binary), +//! where it had lived for no structural reason: its only imports are `std` and +//! this crate's own `BufferWriter` / `Player`, i.e. it never depended on Slint +//! at all. The Qt port had no streaming feeder because of that placement, so +//! its Plex path downloaded the WHOLE file before the first note — 58 MB, and +//! measurably slower than a Qobuz track streamed off the internet. +//! +//! ADR-006 (frontend-agnostic core) is the rule this restores. +//! +//! DEBT: the Slint copy is still there and untouched, deliberately — moving it +//! would force a rebuild of the Slint binary, which pulls `qbz-ui` and its +//! ~30 GB compile that this box cannot verify. Collapse the two the next time +//! the Slint side is built: `crates/qbz/src/remote_stream.rs` becomes +//! `pub use qbz_player::remote_stream::*;`. + +//! Shared HTTP streaming feeder. +//! +//! Ports the Tauri `track_loading.rs` progressive feeder verbatim: probe a +//! remote audio URL for size + FLAC format, open the player's progressive +//! streaming sink (`Player::play_streaming_dynamic`), then push the body to the +//! returned `BufferWriter` chunk-by-chunk as it arrives. Playback starts as soon +//! as the initial buffer fills — not after the whole file lands. +//! +//! `reqwest + BufferWriter` bound only, so it stays frontend-side and never +//! crosses the qconnect-app boundary. Used by BOTH the QConnect renderer +//! (`qconnect_engine.rs`) and the Plex playback path (`playback.rs`), so there +//! is exactly one feeder. +//! +//! BIT-PERFECT: `play_streaming_dynamic` decodes the same original bytes and +//! drives the PROTECTED device init from the decoded stream. The +//! `sample_rate`/`bit_depth` parsed here are only the streaming-config hints; +//! the audio backend (`pipewire_backend.rs`, `init_device`, `audio_settings.rs`) +//! is untouched. + +use std::time::Duration; + +use crate::{BufferWriter, Player}; + +/// Format/size facts sniffed from a remote audio URL before streaming. +pub struct RemoteStreamInfo { + pub content_length: u64, + pub sample_rate: u32, + pub channels: u16, + pub bit_depth: u32, + pub speed_mbps: f64, +} + +/// Probe + open the progressive sink + spawn the background feeder. +/// +/// On success the player has begun buffering and `play_streaming_dynamic` will +/// start audio once the initial buffer fills; the body download runs in a +/// spawned task. Errors here mean the caller should fall back to a full +/// download (the probe or the sink open failed). +pub async fn stream_remote_track_into_player( + player: &Player, + track_id: u64, + duration_secs: u64, + start_position_secs: u64, + url: &str, + log_tag: &str, +) -> Result<(), String> { + let stream_info = probe_remote_stream_info(url).await?; + log::info!( + "[{}/STREAMING] Track {} - {:.2} MB, {}Hz, {} ch, {}-bit, {:.1} MB/s", + log_tag, + track_id, + stream_info.content_length as f64 / (1024.0 * 1024.0), + stream_info.sample_rate, + stream_info.channels, + stream_info.bit_depth, + stream_info.speed_mbps + ); + + let writer = player + .play_streaming_dynamic( + track_id, + stream_info.sample_rate, + stream_info.channels, + stream_info.bit_depth, + stream_info.content_length, + stream_info.speed_mbps, + duration_secs, + start_position_secs, + ) + .map_err(|err| format!("start streaming remote track {track_id}: {err}"))?; + + let url = url.to_string(); + let content_length = stream_info.content_length; + let log_tag = log_tag.to_string(); + tokio::spawn(async move { + if let Err(err) = + download_and_stream_remote_track(&url, writer, track_id, content_length, &log_tag).await + { + log::error!( + "[{}/STREAMING] Track {} failed while streaming: {}", + log_tag, + track_id, + err + ); + } + }); + + Ok(()) +} + +/// HEAD for content-length, then a small `Range: bytes=0-65535` GET to (a) +/// measure throughput and (b) parse the FLAC `STREAMINFO` block for the real +/// sample rate / channels / bit depth. Never defaults silently for FLAC (a +/// wrong sample rate would silently resample hi-res). +pub async fn probe_remote_stream_info(url: &str) -> Result { + use std::time::Instant; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|err| format!("create stream probe client: {err}"))?; + + let head_response = client + .head(url) + .header("User-Agent", "Mozilla/5.0") + .send() + .await + .map_err(|err| format!("probe HEAD request failed: {}", describe_reqwest_error(&err)))?; + + if !head_response.status().is_success() { + return Err(format!( + "probe HEAD request failed with status {}", + head_response.status() + )); + } + + let content_length = head_response + .headers() + .get("content-length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| "probe missing content-length header".to_string())?; + + let start_time = Instant::now(); + let range_response = client + .get(url) + .header("User-Agent", "Mozilla/5.0") + .header("Range", "bytes=0-65535") + .send() + .await + .map_err(|err| format!("probe range request failed: {}", describe_reqwest_error(&err)))?; + + if !range_response.status().is_success() { + return Err(format!( + "probe range request failed with status {}", + range_response.status() + )); + } + + let initial_bytes = range_response + .bytes() + .await + .map_err(|err| format!("read probe bytes failed: {}", describe_reqwest_error(&err)))?; + + let elapsed = start_time.elapsed(); + let speed_mbps = if elapsed.as_secs_f64() > 0.0 { + (initial_bytes.len() as f64 / elapsed.as_secs_f64()) / (1024.0 * 1024.0) + } else { + 10.0 + }; + + // STREAMINFO parse via the shared prober (hoisted to qbz-models for the + // cast path, #638 fix 1). The prober never guesses — the CD-shaped + // defaults for a non-FLAC probe stay HERE so this path's behavior is + // byte-identical to the original inline parse. + let (sample_rate, channels, bit_depth) = match qbz_models::probe_streaminfo(&initial_bytes) { + Some(p) => (p.sample_rate, p.channels, p.bits_per_sample), + None => { + log::warn!("[remote-stream] Non-FLAC probe for remote handoff, using defaults"); + (44_100, 2, 16) + } + }; + + Ok(RemoteStreamInfo { + content_length, + sample_rate, + channels, + bit_depth, + speed_mbps, + }) +} + +/// Plain full-body GET → `bytes_stream()` loop → `writer.push_chunk` → +/// `writer.complete()`. No HTTP Range on the main GET (the `BufferedMediaSource` +/// buffers every pushed byte and serves seeks from the growing buffer). +pub async fn download_and_stream_remote_track( + url: &str, + writer: BufferWriter, + track_id: u64, + content_length: u64, + log_tag: &str, +) -> Result<(), String> { + use futures_util::StreamExt; + use std::time::Instant; + + struct FailGuard { + writer: BufferWriter, + armed: bool, + } + impl Drop for FailGuard { + fn drop(&mut self) { + if self.armed { + let _ = self + .writer + .error("remote stream aborted before completion".into()); + } + } + } + let mut guard = FailGuard { + writer, + armed: true, + }; + let writer = &guard.writer; + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(300)) + .build() + .map_err(|err| format!("create remote streaming client: {err}"))?; + + let response = client + .get(url) + .header("User-Agent", "Mozilla/5.0") + .send() + .await + .map_err(|err| { + format!( + "start remote streaming request failed: {}", + describe_reqwest_error(&err) + ) + })?; + + if !response.status().is_success() { + return Err(format!( + "remote streaming request failed with status {}", + response.status() + )); + } + + let mut bytes_received = 0u64; + let mut stream = response.bytes_stream(); + let start_time = Instant::now(); + let mut last_log_time = Instant::now(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result + .map_err(|err| format!("remote streaming chunk failed: {}", describe_reqwest_error(&err)))?; + bytes_received += chunk.len() as u64; + + if let Err(err) = writer.push_chunk(&chunk) { + log::error!( + "[{}/STREAMING] Failed to push chunk for track {}: {}", + log_tag, + track_id, + err + ); + guard.armed = false; + let _ = writer.error(format!("push_chunk failed: {err}")); + return Err(format!("push_chunk failed: {err}")); + } + + let now = Instant::now(); + if now.duration_since(last_log_time) >= Duration::from_secs(2) && content_length > 0 { + let progress = (bytes_received as f64 / content_length as f64) * 100.0; + let avg_speed = + (bytes_received as f64 / start_time.elapsed().as_secs_f64()) / (1024.0 * 1024.0); + log::info!( + "[{}/STREAMING] Track {} {:.1}% ({:.2}/{:.2} MB) @ {:.2} MB/s", + log_tag, + track_id, + progress, + bytes_received as f64 / (1024.0 * 1024.0), + content_length as f64 / (1024.0 * 1024.0), + avg_speed + ); + last_log_time = now; + } + } + + guard.armed = false; + if let Err(err) = writer.complete() { + log::error!( + "[{}/STREAMING] Failed to mark stream complete for track {}: {}", + log_tag, + track_id, + err + ); + let _ = writer.error(format!("complete failed: {err}")); + return Err(format!("complete failed: {err}")); + } + + log::info!( + "[{}/STREAMING] Track {} complete: {:.2} MB in {:.1}s", + log_tag, + track_id, + bytes_received as f64 / (1024.0 * 1024.0), + start_time.elapsed().as_secs_f64() + ); + + Ok(()) +} + +/// reqwest's `Display` hides the source chain — which is exactly where the +/// diagnosis lives (Akamai's >100-header small-object flood surfaces as hyper's +/// "message head is too large" two levels down). Walk `source()` and join the +/// chain so logs AND signature matching see the real cause. +pub fn describe_reqwest_error(err: &reqwest::Error) -> String { + use std::error::Error as _; + let mut out = err.to_string(); + let mut source = err.source(); + while let Some(cause) = source { + out.push_str(": "); + out.push_str(&cause.to_string()); + source = cause.source(); + } + out +} + +/// True when an error message (already chain-expanded by +/// [`describe_reqwest_error`]) shows hyper's hard-coded h1 100-header cap. +/// Akamai answers SMALL raw-url objects with ~106 headers (the `X-AK-GRN` / +/// `X-AK-FWD-ERROR: ERR_POC_FWD_OBJ_TOO_SMALL` flood), so EVERY reqwest fetch +/// of such an URL fails this way — streaming probe and full download alike. +pub fn is_header_flood_error(message: &str) -> bool { + let haystack = message.to_ascii_lowercase(); + haystack.contains("message head is too large") || haystack.contains("too many headers") +} diff --git a/crates/qbz-plex/src/lib.rs b/crates/qbz-plex/src/lib.rs index b45e2d265..a7a1adc89 100644 --- a/crates/qbz-plex/src/lib.rs +++ b/crates/qbz-plex/src/lib.rs @@ -324,8 +324,32 @@ fn open_plex_cache_db() -> Result { Ok(conn) } +/// The default-timeout Plex client, built ONCE. +/// +/// It used to be rebuilt on every call, and a fresh `reqwest::Client` is not +/// free: new connection pool (so no keep-alive to reuse), fresh TLS handshake, +/// and a fresh DNS resolution. On Plex that last one is the expensive part — +/// LAN servers are addressed as `https://..plex.direct:32400`, and +/// `*.plex.direct` resolves through Plex's own nameservers, i.e. over the +/// INTERNET. So every play paid an internet DNS round trip plus a TLS handshake +/// before the first byte of a file that was sitting on the local network. +/// +/// That is the shape the owner reported (2026-08-13): playing a Plex track took +/// LONGER than streaming a Qobuz track from the internet, which no amount of +/// LAN transfer time can explain — but a per-call client can, because the Qobuz +/// path reuses one long-lived client and this one threw its away every time. +/// +/// Cached here rather than at the call sites so every Plex entry point gets it. +/// `build_plex_client_with_timeout` stays uncached: its callers pick a short +/// bound on purpose (quality hydration), and those are genuinely different +/// clients. +static PLEX_CLIENT: std::sync::OnceLock> = + std::sync::OnceLock::new(); + fn build_plex_client() -> Result { - build_plex_client_with_timeout(Duration::from_secs(120)) + PLEX_CLIENT + .get_or_init(|| build_plex_client_with_timeout(Duration::from_secs(120))) + .clone() } /// Build the LAN Plex client with a caller-chosen request timeout. Quality diff --git a/crates/qbz-qt/qml/shell/Cortinilla.qml b/crates/qbz-qt/qml/shell/Cortinilla.qml index 146962141..3347281b2 100644 --- a/crates/qbz-qt/qml/shell/Cortinilla.qml +++ b/crates/qbz-qt/qml/shell/Cortinilla.qml @@ -511,9 +511,22 @@ Item { // doing nothing. The highlight above already guards with // `?? -2`; this is the same guard on the action. var fi = row.flatIndex - if (fi === undefined || fi === null) return - if (root.headerBar) root.headerBar.clearSearch() + if (fi === undefined || fi === null) { + // Was a bare `return`: the one failure mode that leaves NO + // trace anywhere, which is exactly what a dead click looks + // like from the outside. + console.warn("cortinilla: row has no flatIndex, click dropped —", + JSON.stringify(row)) + return + } + // ORDER MATTERS. clearSearch() empties the header field, which + // drives the live-search path; if that reaches Rust before the + // click does, it can replace or clear the very snapshot the + // router is about to look the row up in — and the router then + // returns silently. The click goes FIRST now; the field is + // cleared after, which is the visible behaviour either way. QbzSearch.cortinillaRowClicked(fi) + if (root.headerBar) root.headerBar.clearSearch() } } } diff --git a/crates/qbz-qt/qml/shell/TransportControls.qml b/crates/qbz-qt/qml/shell/TransportControls.qml index 71ff5f3e5..fc950caa7 100644 --- a/crates/qbz-qt/qml/shell/TransportControls.qml +++ b/crates/qbz-qt/qml/shell/TransportControls.qml @@ -67,6 +67,20 @@ Row { anchors.verticalCenter: parent.verticalCenter onClicked: QbzPlayer.toggleShuffle() } + // True while the player is resolving/fetching the track that is about to + // play. Not gated on `npHasTrack`: the first play of a session has no + // track yet, and that is exactly the case that felt dead. + readonly property bool loading: QbzPlayer.npLoading + // Spin phase, advanced on the shared shell pulse (QbzShell.pulseMs). + property real spinPhase: 0 + Connections { + target: QbzShell + function onPulseMsChanged() { + if (tc.loading) + tc.spinPhase = (tc.spinPhase + 0.42) % 6.283185 + } + } + QbzIconButton { name: "skip-back" btnEnabled: QbzPlayer.npHasTrack @@ -83,6 +97,10 @@ Row { ? (playArea.containsMouse && QbzPlayer.npHasTrack ? theme.accentHover : theme.accent) : ((playArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent") QbzIcon { + // Hidden while loading — the ring below stands in for it, so the + // button never shows a play glyph for a track that is still being + // fetched. + visible: !tc.loading name: QbzPlayer.npPlaying ? "pause" : "play-fill" width: 20 height: 20 @@ -90,6 +108,37 @@ Row { tintName: tc.playCircle ? tc.tintOnAccent : (playArea.containsMouse ? "accent" : tc.tintStrong) } + // LOADING RING. Between the click and the first sample there was NO + // affordance anywhere in the shell, so a fetch that takes seconds — a + // Plex part, a cold CMAF session — reads as a dead click (owner report, + // 2026-08-13). `npLoading` already existed and was published + // (now_playing.rs) but the only consumer was SongCard's artwork + // spinner, which is gated on `npHasTrack` and so shows nothing on the + // first play of a session. + // + // ON THE SHELL PULSE, not a NumberAnimation: a rotation animator ticks + // at display rate and every animation frame is a whole-window present + // (CLAUDE.md, "the repaint pulse"). At ~30 Hz this costs zero extra + // presents because the pulse is already running. + Item { + anchors.fill: parent + visible: tc.loading + Rectangle { + id: ring + anchors.centerIn: parent + width: parent.width - 8 + height: width + radius: width / 2 + color: "transparent" + border.width: 2 + border.color: tc.playCircle ? theme.accentText : theme.accent + // A 270-degree arc read: three quarters of the ring at full + // alpha and the last quarter masked by the fill behind it is + // not expressible on a Rectangle, so the spin is carried by + // opacity pulsing instead — one property, no extra node. + opacity: 0.35 + 0.45 * (1 + Math.sin(tc.spinPhase)) / 2 + } + } MouseArea { id: playArea anchors.fill: parent diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index 45a6f2fd0..29a1b0559 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -516,7 +516,12 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: QbzPlayer.playTrack(card.id) + // The row's ORIGIN goes with the id. A Recently-Played row can be a + // Qobuz track, a local file or a Plex row, and `playTrack` alone + // sends every id to the Qobuz catalog — which 404s on the other two + // (the owner hit it on a Plex id from this exact rail). Empty + // source = qobuz, which is the legacy history shape. + onClicked: QbzPlayer.playTrackFrom(card.id, card.source || "") } } diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs index 5fec2ab4b..feaa01a84 100644 --- a/crates/qbz-qt/src/auth_qt.rs +++ b/crates/qbz-qt/src/auth_qt.rs @@ -156,6 +156,11 @@ where // Plex settings live per-user (plex_settings.db); bind the store to // this session so the first Local Library read does not have to. crate::local_plex::init_for_user(&dir); + // Session persistence (queue + current track + position). The store is + // per-user (`session.db`) and the gates are seeded from this user's + // playback prefs inside init_for_user, so it works before Settings has + // published anything. + qbz_app::session_persist::init_for_user(&dir); // Phase 5: local-favorites store (Library show-local + hearts). crate::library_qt::init_local_favorites(&dir); // Qobuz favourite-id cache (favorites_cache.db, shared with the Slint @@ -298,6 +303,8 @@ where // store to this session so the first Local Library read does // not have to. crate::local_plex::init_for_user(&dir); + // Session persistence — see the fresh-login path. + qbz_app::session_persist::init_for_user(&dir); crate::library_qt::init_local_favorites(&dir); // Qobuz favourite-id cache — see the fresh-login path. crate::fav_cache_qt::init_for_user(&dir); @@ -372,6 +379,11 @@ where // Plex settings live per-user (plex_settings.db); bind the store to // this session so the first Local Library read does not have to. crate::local_plex::init_for_user(&dir); + // Session persistence (queue + current track + position). The store is + // per-user (`session.db`) and the gates are seeded from this user's + // playback prefs inside init_for_user, so it works before Settings has + // published anything. + qbz_app::session_persist::init_for_user(&dir); // Phase 5: local-favorites store (Library show-local + hearts). crate::library_qt::init_local_favorites(&dir); // Qobuz favourite-id cache. The disk seed is the ONLY source offline diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs index 7d6dcf611..6937453d6 100644 --- a/crates/qbz-qt/src/home_qt.rs +++ b/crates/qbz-qt/src/home_qt.rs @@ -72,6 +72,13 @@ pub struct HomeCard { pub ribbon: String, #[serde(rename = "ribbonKind")] pub ribbon_kind: String, + /// Where the row came from: "qobuz" | "local" | "plex" (empty = qobuz, the + /// legacy history shape). CARRIED, not inferred: playback routes on it, and + /// a local id handed to the Qobuz catalog 404s or opens a different album + /// that happens to share the number. `slimTracks` is the rail that needs + /// it; every other kind leaves it empty and it stays off the wire. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source: String, #[serde(rename = "isPinned", default)] pub is_pinned: bool, /// Heart state at BUILD time, from `fav_cache_qt` — the row's own kind @@ -1274,6 +1281,9 @@ fn map_recent_track(t: crate::recently_qt::RecentTrack) -> HomeCard { id: t.id, title: t.title, artist: t.subtitle, + // The one line this whole fix turns on: the history stores the origin + // and the card used to drop it on the floor via `..default()`. + source: t.source, art_url: if t.artwork_url.is_empty() { t.album_artwork_url } else { diff --git a/crates/qbz-qt/src/local_playback.rs b/crates/qbz-qt/src/local_playback.rs index 23da5f27d..80fa5ff74 100644 --- a/crates/qbz-qt/src/local_playback.rs +++ b/crates/qbz-qt/src/local_playback.rs @@ -302,12 +302,90 @@ pub async fn play_plex_track(runtime: &Runtime, rating_key: String, play_id: u64 log::error!("[qbz-qt] plex play: no Plex credentials configured"); return false; } + // TIMED, because the shape of this delay does not match its obvious + // explanation. The whole-file download below is a known POC gap, but the + // owner measured this path taking LONGER than a Qobuz track that streams + // over the internet — and 58 MB over a LAN cannot do that. So before any + // redesign, the log says which segment actually costs: the metadata round + // trip, the transfer, or the handoff to the player. It also prints the base + // URL, because `resolve_base_url` can hand back the plex.tv RELAY instead + // of the LAN address, and a relayed transfer is a round trip through Plex's + // servers — that alone would outweigh a local download and would explain + // being slower than remote Qobuz. + let t0 = std::time::Instant::now(); + let base = cfg.base_url.clone(); + let is_lan = crate::local_plex::is_local_address(&base); + + // PROGRESSIVE FIRST. `plex_resolve_part_url` stops at the URL and the + // shared feeder Range-streams the original bytes, so audio starts on the + // first chunk instead of after the whole FLAC is in RAM. The doc on + // `PlexPartLocation` states the intent outright — "~1s to first audio + // instead of buffering the whole FLAC into RAM first" — and it went unused + // here only because the feeder lived inside the Slint binary until it was + // moved to `qbz_player::remote_stream`. + // + // Duration comes from the queue row (the feeder needs it for its buffer + // maths); 0 is acceptable — it only makes the estimate conservative. + let duration = runtime + .core() + .current_track() + .await + .map(|t| t.duration_secs as u64) + .unwrap_or(0); + match qbz_plex::plex_resolve_part_url(base.clone(), cfg.token.clone(), rating_key.clone()).await + { + Ok(loc) => { + let resolved = t0.elapsed(); + match qbz_player::remote_stream::stream_remote_track_into_player( + &runtime.core().player(), + play_id, + duration, + 0, + &loc.part_url, + "PLEX", + ) + .await + { + Ok(()) => { + log::info!( + "[qbz-qt][perf] plex play {play_id}: STREAMED — resolve {resolved:?}, \ + first audio {:?} — base {} ({})", + t0.elapsed(), + base, + if is_lan { "LAN" } else { "NOT a LAN address — relayed?" }, + ); + return true; + } + // The whole-file path below stays as the fallback, exactly as + // the reference keeps it: a server that refuses Range, or a + // part that will not probe, still plays — just slowly. + Err(e) => log::warn!( + "[qbz-qt] plex play {play_id}: streaming failed ({e}) — \ + falling back to whole-file download" + ), + } + } + Err(e) => log::warn!("[qbz-qt] plex play: part-url resolve failed ({e}) — full download"), + } match qbz_plex::plex_resolve_track_media(cfg.base_url, cfg.token, rating_key.clone()).await { Ok(media) => { + let fetched = t0.elapsed(); + let bytes = media.bytes.len(); + let t1 = std::time::Instant::now(); if let Err(e) = runtime.core().player().play_data(media.bytes, play_id) { log::error!("[qbz-qt] plex play: play_data {play_id} failed: {e}"); return false; } + log::info!( + "[qbz-qt][perf] plex play {play_id}: resolve+fetch {:?} for {} bytes \ + ({:.1} MB/s), play_data {:?} — base {} ({})", + fetched, + bytes, + (bytes as f64 / 1_048_576.0) / fetched.as_secs_f64().max(0.001), + t1.elapsed(), + base, + if is_lan { "LAN" } else { "NOT a LAN address — relayed?" }, + ); true } Err(e) => { @@ -685,3 +763,77 @@ mod tests { assert_eq!(plex_rating_key(None, 771), "771"); } } + +// --------------------------------------------------------------------------- +// Source-aware single-track play (Home's Recently-Played rail, 2026-08-13) +// --------------------------------------------------------------------------- + +/// Play ONE non-Qobuz track by its queue id, resolving it through its own +/// source. Returns false when the row cannot be resolved. +/// +/// WHY THIS EXISTS. `playback_qt::queue_track_for` has exactly two arms — the +/// Qobuz favourites feed, then `get_track` against the catalog — so any id that +/// misses the feed goes to the Qobuz API. For a local or Plex id that is a +/// guaranteed 404 (reported by the owner from Discover's Recently-Played rail: +/// `get_track(1099511673001)` on a `PLEX_TRACK_ID_FLOOR`-namespaced id). The +/// cortinilla has routed by source since it was written and says why in its own +/// comment: handing a local id to the catalog "would 404 or, worse, open +/// someone else's album that happens to share the number". This is that seam +/// for the rails that reach playback through the generic by-id entry. +/// +/// ROUTING IS BY THE ROW'S `source`, NEVER BY ID ARITHMETIC. `id >= +/// PLEX_TRACK_ID_FLOOR` is only unambiguous for Plex; plain local ids are small +/// `library.db` rowids that CAN collide with real Qobuz track ids, so a +/// speculative `get_track()` on the local DB could play the wrong file. It is +/// also what keeps Jellyfin/Navidrome a match arm here instead of another sweep +/// through the tree. +/// +/// An unresolvable row plays NOTHING and says so. Falling back to the Qobuz +/// path would reproduce the 404 this exists to remove, and falling back to +/// "some other track" is the failure the cortinilla explicitly refuses. +pub async fn play_single_from_source(runtime: &Runtime, track_id: u64, source: &str) -> bool { + let track = match source { + // A library row: the DB has everything, and `local_queue_track` builds + // the QueueTrack the audible step expects. + "local" | "qobuz_download" => { + tokio::task::spawn_blocking(move || with_db(|db| db.get_track(track_id as i64)).flatten()) + .await + .ok() + .flatten() + } + // A Plex row is SYNTHETIC — `local_plex::map_cached_to_local_track` + // mints it and it never touches library.db, so there is no row to read. + // The cache is queried by ALBUM, and the recently-played entry carries + // the album artist/title, which is exactly the key. Resolving through + // the real cached row is what recovers `rating_key` (the audible step + // needs it as `source_item_id_hint`, and it is NOT derivable from the + // namespaced id: the id packs the cache ROWID, the hint is a different + // column). + "plex" => { + let entry = crate::recently_qt::find_track(&track_id.to_string()); + let Some(entry) = entry else { + log::warn!("[qbz-qt] plex play: {track_id} is not in the recently-played store"); + return false; + }; + let key = crate::local_plex::album_key_for(&entry.album_artist, &entry.album_title); + tokio::task::spawn_blocking(move || { + crate::local_plex::album_tracks(&key) + .into_iter() + .find(|t| t.id as u64 == track_id) + }) + .await + .ok() + .flatten() + } + other => { + log::warn!("[qbz-qt] play: unknown source {other:?} for track {track_id}"); + return false; + } + }; + let Some(track) = track else { + log::warn!("[qbz-qt] play: {source} track {track_id} could not be resolved"); + return false; + }; + play_rows(runtime, vec![track], 0, false).await; + true +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 63f7b9af4..bc75a26b2 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -353,6 +353,52 @@ static SIDEBAR_LOADED: Mutex = Mutex::new(false); /// (`on_session_entered`) and again on logout, so neither a logout -> login nor /// an offline -> login transition can leave a new session reading the previous /// one's answer. Each latch's own doc explains what it gates. +/// Restore the persisted queue + current track, once per process. +/// +/// PAUSED by design (the shared module's "Phase A"): the queue and the cursor +/// come back, the audio does not start itself. The saved position rides along +/// and is consumed by the first play of that same track +/// (`session_persist::take_resume_for`, threaded through +/// `play_resolved_offline_aware`). +fn restore_session_once() { + static DONE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if DONE.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + // Bind the exit context here rather than at startup: this is the first + // moment a runtime exists AND a user session owns a store, so a snapshot + // taken on quit can never belong to nobody. `OnceLock` — later entries are + // ignored, which is what we want (the runtime is process-global anyway). + qbz_app::session_persist::bind_exit_ctx( + app(), + TOKIO.get().expect("tokio runtime set before the shell mounts").handle().clone(), + ); + // Crash-chain rung 3: two consecutive boots died even after the view reset, + // so skip the queue restore for THIS boot only. The persisted queue stays + // on disk untouched — a healthy boot brings it back. + if nav_qt::crash_level() >= 3 { + log::warn!("[crash-chain] session restore bypassed this boot (queue kept on disk)"); + return; + } + spawn(async move { + let runtime = app(); + if !qbz_app::session_persist::restore(&runtime).await { + return; + } + // The queue exists in the core now; the bar and the queue panel repaint + // from explicit refreshes, not from the core mutation. + playback_qt::refresh_now_playing(&runtime).await; + // AFTER the meta publish, never before: `refresh_now_playing` goes + // through `set_track`, which arms loading+playing for the real play + // path. Nothing was dispatched here, so those have to be taken back + // down or the app opens with the play button spinning. + now_playing::mark_restored_idle(); + playback_qt::publish_queue(&runtime).await; + let resume = qbz_app::session_persist::pending_resume_position(); + log::info!("[qbz-qt] session restored (resume position {resume}s)"); + }); +} + fn reset_session_latches() { *HOME_LOADED.lock().unwrap() = false; *LIBRARY_LOADED.lock().unwrap() = false; @@ -479,7 +525,7 @@ fn on_session_entered() { reset_session_latches(); // Phase 2: the shell mounts on the (only) "home" view; seed the nav // history and push the current now-playing model onto the bar. - nav_qt::record("home"); + nav_qt::record(&nav_qt::shell_entry_view()); now_playing::publish_current(); // Phase 3: fetch Discover > Home (online sessions only — the offline // engine gates Qobuz calls anyway, and the view shows the offline @@ -487,6 +533,12 @@ fn on_session_entered() { load_home_once(); // Phase 4: the 1 Hz playback state pump (idempotent). playback_qt::start_poll_loop(app()); + // Phase 4b: session restore (queue + current track, PAUSED). One-shot per + // process — `on_session_entered` is multi-entry (login, session restore, + // "Start offline") and restoring again on a re-login would clobber whatever + // the user has queued since. Runs AFTER the poll loop so the track-change + // edge is already listening when the restored cursor lands. + restore_session_once(); // Integrations runtime: applies the persisted MusicBrainz / Discord / // ListenBrainz opt-ins and starts the scrobble-queue flush watcher. // Strictly opt-in — every one of them is inert until the user connects it. @@ -1342,6 +1394,7 @@ pub(crate) fn enqueue_artist_top() { /// Track-row click (Library tracks): one-track queue through the core. pub(crate) fn play_track(track_id: u64) { search_qt::record_page_interaction("track", &track_id.to_string(), search_qt::InteractionAction::Play); + now_playing::begin_loading(); let runtime = app(); spawn(async move { if let Err(e) = playback_qt::play_single_track(&runtime, track_id).await { @@ -1350,6 +1403,34 @@ pub(crate) fn play_track(track_id: u64) { }); } +/// `play_track` with the row's ORIGIN attached — the single router for rails +/// that can show non-Qobuz tracks (Home's Recently-Played, and any future +/// mixed feed). Qobuz keeps the existing path byte for byte; everything else +/// resolves through its own source and never touches the catalog. +pub(crate) fn play_track_from(track_id: u64, source: String) { + if source.is_empty() || source == "qobuz" { + play_track(track_id); + return; + } + search_qt::record_page_interaction( + "track", + &track_id.to_string(), + search_qt::InteractionAction::Play, + ); + // Spinner ON at DISPATCH: everything after this is async, and a local file + // read or a Plex part fetch can take seconds with nothing on screen. + now_playing::begin_loading(); + let runtime = app(); + spawn(async move { + // No Qobuz fallback on failure, deliberately: that is the 404 this + // router exists to remove, and guessing another row is the failure the + // cortinilla refuses by design (search_qt.rs:1172). + if !local_playback::play_single_from_source(&runtime, track_id, &source).await { + log::error!("[qbz-qt] play_track_from: {source} track {track_id} did not play"); + } + }); +} + // ============================ Playlist view + DnD (phase 17) ============== /// Open the playlist detail view (sidebar row / playlist card click). @@ -2372,6 +2453,10 @@ pub(crate) fn reload_home() { + sections.for_you.iter().map(|s| s.items.len()).sum::(); publish_home_sections(§ions); + // LIVENESS. The shell has a mounted view with real content, so + // whatever was restored this boot did not kill it — clear the + // crash chain for the next start. + nav_qt::mark_startup_healthy(); log::info!( "[qbz-qt] home published: {}+{}+{} sections, {} cards, {} artwork misses", sections.home.len(), @@ -2574,6 +2659,14 @@ pub(crate) fn arm_hard_exit_watchdog(source: &'static str) { fn main() { qbz_log::install("info"); + // BEFORE any restore reads a pref: read + increment the crash chain, so a + // boot that dies from a restored view or queue degrades the NEXT boot + // instead of trapping the user in a loop it cannot escape from inside the + // UI. Cleared by `mark_startup_healthy` once the shell is usable. + nav_qt::arm_startup_probe(); + // Hand the level to the shared persistence module: the queue restore has + // its own rung on the same ladder (>=3 skips it for this boot only). + qbz_app::session_persist::set_crash_chain_level(nav_qt::crash_level()); // glibc resolver hardening (2026-08-10): this host's path to its first // resolver drops the SECOND of two parallel DNS queries on one socket, // so every getaddrinfo (which fires A+AAAA in parallel) stalls for the @@ -2653,6 +2746,19 @@ fn main() { // the watchdog `_exit(0)`s the process. Idempotent — the quit paths // arm it earlier, at the moment quit was requested. arm_hard_exit_watchdog("event-loop exit"); + // Final full snapshot. Placed here — after the loop and AFTER the + // watchdog — rather than in each QML quit handler: window close, tray + // "Quit" and the hotkey all converge on this line, so one call covers + // paths that four scattered ones would keep missing. (The reference + // flushes from the handlers instead — `tray/mod.rs:300`.) + // + // ORDER IS LOAD-BEARING: this is a synchronous `block_on` of a SQLite + // write on the main thread with no UI left. Behind the watchdog it can + // wedge and the process still dies in 5s; in front of it, a wedge is an + // app with no window that only `kill -9` ends — the 2026-08-04 quit + // incident exactly. No-op unless `persist_session` is on. + qbz_app::session_persist::save_on_exit(); + // Same reason as logout: leaving the app must stop the renderer. // // BOUNDED. This is a `block_on` on the main thread after the UI is diff --git a/crates/qbz-qt/src/nav_qt.rs b/crates/qbz-qt/src/nav_qt.rs index b54aa09e3..ad15ae9cb 100644 --- a/crates/qbz-qt/src/nav_qt.rs +++ b/crates/qbz-qt/src/nav_qt.rs @@ -43,14 +43,172 @@ static HISTORY: Mutex> = Mutex::new(None); fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { let mut guard = HISTORY.lock().unwrap(); let history = guard.get_or_insert_with(|| NavHistory { - entries: vec!["home".to_string()], + // Seeded with the STARTUP view, not a literal "home": booting on a + // restored view with a history rooted at Home would offer a Back to a + // page the user never opened this session. + entries: vec![startup_view()], index: 0, }); f(history) } /// Push a view as the new current entry (no-op when it IS the current one). +/// The top-level views it is SAFE to reopen the app on: no required id, so a +/// restore can never land on a detail page whose entity is gone. +/// +/// 1:1 with the reference's `last_view` contract (`ui_prefs.rs:489-492`), which +/// spells the set out and adds "Detail views (album/artist/playlist/…) are never +/// stored". Search and Settings are excluded on the same reasoning the reference +/// gives for `last_nav`: one is transient, the other is config — reopening the +/// app inside either is not "where you left off", it is a surprise. +/// `last_view` is written into the SHARED `ui_prefs.json` — the same file the +/// Slint build reads — so the value stored has to be the REFERENCE's +/// vocabulary, not this port's route ids. Four of the six differ: +/// +/// | reference (`ui_prefs.rs:489`) | this port's route | +/// |---|---| +/// | `home` | `home` | +/// | `favorites` | `library` | +/// | `local-library` | `local` | +/// | `mixtapes` | `mixtapes` | +/// | `collections` | `collections` | +/// | `discover` | (no twin — the reference splits Discover from Home) | +/// +/// Writing `local` / `library` verbatim would have looked correct here and +/// silently broken the Slint build's restore for anyone who runs both — the +/// owner's own prefs today read `last_view = "local-library"`, written by Slint. +/// Unknown values in either direction resolve to Home rather than guessing. +/// +/// Detail views (album/artist/playlist/…) are never stored: they need an id and +/// a restore could land on an entity that is gone. Search and Settings are +/// excluded too — transient and config respectively, and reopening the app +/// inside either is a surprise, not "where you left off". +const VIEW_TO_PREF: &[(&str, &str)] = &[ + ("home", "home"), + ("library", "favorites"), + ("local", "local-library"), + ("mixtapes", "mixtapes"), + ("collections", "collections"), +]; + +fn pref_for_view(view: &str) -> Option<&'static str> { + VIEW_TO_PREF.iter().find(|(v, _)| *v == view).map(|(_, p)| *p) +} + +fn view_for_pref(pref: &str) -> Option<&'static str> { + VIEW_TO_PREF + .iter() + .find(|(_, p)| *p == pref) + .map(|(v, _)| *v) + // The reference's `discover` has no separate route here: this port's + // Discover IS `home`. Accept it on READ so a prefs file written by the + // Slint build restores somewhere sensible instead of falling to Home. + .or(if pref == "discover" { Some("home") } else { None }) +} + +/// Crash-chain guard for the restore paths. +/// +/// A restore that crashes is a TRAP: the app dies on boot, and every retry +/// restores the same thing and dies again, with no way out from inside the UI. +/// The reference guards it with a probe file and a recovery ladder +/// (`main.rs:7218-7240`), and it is worth carrying because the failure mode is +/// "the app no longer starts", not "a page looks wrong". +/// +/// The ladder, and it never DELETES state — it bypasses it: +/// - level 1 (previous boot reached liveness): restore everything. +/// - level 2 (one prior boot died first): ignore the remembered view, open Home. +/// - level >= 3: additionally skip the persisted queue restore for this boot. +/// The queue file itself is left untouched. +fn probe_path() -> Option { + dirs::data_dir().map(|d| d.join("qbz").join("startup_probe_qt")) +} + +static CRASH_LEVEL: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); + +/// Read + increment the probe. Call ONCE, early in boot, before any restore. +pub fn arm_startup_probe() { + let Some(path) = probe_path() else { return }; + let prev: u8 = std::fs::read_to_string(&path) + .ok() + .and_then(|t| t.trim().parse().ok()) + .unwrap_or(0); + let level = prev.saturating_add(1); + CRASH_LEVEL.store(level, std::sync::atomic::Ordering::Relaxed); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&path, level.to_string()); + if level > 1 { + log::warn!( + "[qbz-qt] startup probe: level {level} — {} prior boot(s) died before liveness; \ + restore is degraded", + level - 1 + ); + } +} + +/// The app reached a state a user could act on. Clears the chain so the NEXT +/// boot restores normally. +pub fn mark_startup_healthy() { + if let Some(path) = probe_path() { + let _ = std::fs::write(&path, "0"); + } +} + +/// This boot's chain level (1 = the previous shutdown was clean). +pub fn crash_level() -> u8 { + CRASH_LEVEL.load(std::sync::atomic::Ordering::Relaxed).max(1) +} + +/// The view to open on: `startup_page` = "home" always Home, "remember" the last +/// safe top-level view. Unknown keys fall back to Home rather than guessing. +/// +/// The pref row for this has existed in Settings since the settings port and +/// NOTHING read it — the user could pick "Where you left off" and the app opened +/// on Home every time. That is the "renders, persists, drives nothing" defect +/// class this port has a name for. +pub fn startup_view() -> String { + if crate::settings_qt::pref_str("startup_page", "home") != "remember" { + return "home".to_string(); + } + // Ladder level 2+: a prior boot died before liveness, so the remembered + // view is a suspect. Open Home instead of retrying it. + if crash_level() >= 2 { + log::warn!("[qbz-qt] startup: remembered view bypassed (crash chain)"); + return "home".to_string(); + } + let last = crate::settings_qt::pref_str("last_view", "home"); + let resolved = view_for_pref(&last).unwrap_or("home"); + log::info!("[qbz-qt] startup: remember -> last_view {last:?} resolves to view {resolved:?}"); + resolved.to_string() +} + +/// The view the SHELL should mount on at this session entry. +/// +/// `init_shell_for_user` is MULTI-ENTRY (login, session restore, "Start +/// offline"), and it used to `record("home")` unconditionally. That is a +/// literal that outranks the whole startup-page pref: it pushed `home` over the +/// seeded startup entry AND — since `record` persists — rewrote `last_view` to +/// `home` on every boot, so "Where you left off" could never survive its own +/// startup. The restore is a ONE-SHOT: the first entry of the process honours +/// the pref, a later re-entry (re-login inside the same run) is a fresh session +/// and lands on Home the way the reference does. +pub fn shell_entry_view() -> String { + static USED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if USED.swap(true, std::sync::atomic::Ordering::SeqCst) { + return "home".to_string(); + } + startup_view() +} + pub fn record(view: &str) { + // Persist BEFORE the history mutation so the write reflects the view the + // user actually reached, and only for the safe set — a detail view leaves + // the stored value on the last safe root, which is exactly what the + // reference stores. + if let Some(pref) = pref_for_view(view) { + crate::settings_qt::save_pref("last_view", serde_json::json!(pref)); + } let (can_back, can_forward, current) = with_history(|h| { if h.entries[h.index] != view { h.entries.truncate(h.index + 1); diff --git a/crates/qbz-qt/src/now_playing.rs b/crates/qbz-qt/src/now_playing.rs index f6e4fefa9..00885cdfb 100644 --- a/crates/qbz-qt/src/now_playing.rs +++ b/crates/qbz-qt/src/now_playing.rs @@ -478,9 +478,39 @@ pub fn set_artwork_path(path: String) { /// branch normally clears the spinner via `set_position(has_audio = true)`, /// but a refused play never primes the peer, so a paused+idle peer would /// leave the spinner latched. +/// Light the spinner the MOMENT a play is dispatched, before anything is +/// resolved. +/// +/// `loading` used to be set only inside the meta publish, i.e. once the track +/// was already known — which leaves the entire resolve/fetch window with no +/// affordance anywhere in the shell. That window is seconds for a Plex part or +/// a cold CMAF session, and the owner read it as a dead click. The clear side +/// already exists (`clear_loading`, plus the position push once audio flows), +/// so this only moves the START earlier. +pub(crate) fn begin_loading() { + mutate(|m| m.loading = true); +} + pub(crate) fn clear_loading() { mutate(|m| m.loading = false); } + +/// A track's meta is on the bar but NOTHING was dispatched — the session +/// restore at startup. +/// +/// `set_track` asserts `loading = true` and `playing = true` because its normal +/// caller is a real play, and both are cleared by the audio that follows: +/// `set_position(has_audio = true)` turns the spinner off. A restored track has +/// no dispatch behind it and no audio ahead of it, so nothing ever arrives to +/// clear either flag — the app opened with the play button spinning and the +/// transport claiming to play, forever. Restore is the one caller that has to +/// say so explicitly. +pub(crate) fn mark_restored_idle() { + mutate(|m| { + m.loading = false; + m.playing = false; + }); +} pub fn set_position( elapsed_secs: i32, duration_secs: i32, diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 4b294b5d5..313998684 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -427,6 +427,14 @@ pub(crate) async fn play_resolved_offline_aware( ) -> Result<(), String> { let off = crate::offline_qt::get().await; let sink = off.as_ref().map(|_| crate::offline_cache_qt::row_sink()); + // Session resume: if this is the track restored at launch, start it at the + // saved position (consumed once). Only when the caller did not ask for a + // position itself — an explicit seek-then-play must win over the restore. + let start_position_secs = if start_position_secs == 0 { + qbz_app::session_persist::take_resume_for(track_id) + } else { + start_position_secs + }; runtime .core() .play_track_resolved( @@ -1423,13 +1431,21 @@ pub async fn toggle_play(runtime: &Arc>) { } } let event = runtime.core().player().get_playback_event(); - let result = if event.is_playing { + let was_playing = event.is_playing; + let result = if was_playing { runtime.core().pause() } else { runtime.core().resume() }; if let Err(e) = result { log::warn!("[qbz-qt] toggle-play failed: {e}"); + return; + } + if was_playing { + // Persist the paused position: pausing is the strongest signal the user + // is done for now, and the ~5s poll flush can be up to 5s stale. + // No-op unless `persist_session` is on. + qbz_app::session_persist::capture_and_save(runtime).await; } } @@ -1965,6 +1981,8 @@ pub fn start_poll_loop(runtime: Arc>) { let mut last_track_id: u64 = 0; let mut was_playing = false; let mut seen_position: u64 = 0; + // Divider for the ~5s position flush (see the save_position call). + let mut save_pos_tick: u64 = 0; // Track id we have already fired a gapless prefetch for, so this poll // does not re-request it on every tick while the download is in flight // (playback.rs:5049-5051). @@ -2235,6 +2253,14 @@ pub fn start_poll_loop(runtime: Arc>) { crate::recently_qt::record_queue_track(&track); } } + // Persist the session (queue + current track + position) so a + // restart can restore it. Hooked on the DE-DUPED track edge and + // not at each play call site: `play_queue_track` alone has five + // early returns (cast, peer, local, plex, Qobuz) and the album / + // single / shuffle entries add more, so a per-call-site hook is + // a list that silently goes stale. This edge sees them all. + // No-op unless `persist_session` is on. + qbz_app::session_persist::capture_and_save(&runtime).await; last_track_id = track_id; // The engine may have reached this track through a GAPLESS // hand-off, in which case the arming guard still names the @@ -2511,6 +2537,14 @@ pub fn start_poll_loop(runtime: Arc>) { } seen_position = position; + // Persist the live position every ~5s while playing so a crash keeps + // a near-current resume point. The reference ticks at 450ms and uses + // `% 11`; this loop ticks at 1 Hz, so the WALL-CLOCK equivalent is + // `% 5` — tick-count throttles do not translate 1:1. + save_pos_tick = save_pos_tick.wrapping_add(1); + if is_playing && track_id != 0 && save_pos_tick % 5 == 0 { + qbz_app::session_persist::save_position(position); + } // Reflect play/pause into the tray tooltip on the TRANSITION only, // so the "Middle-click to pause/play" hint stays correct without // spamming the updater channel every tick (§7-M12). The second of diff --git a/crates/qbz-qt/src/player_bridge.rs b/crates/qbz-qt/src/player_bridge.rs index cf47d0e85..dfabd911d 100644 --- a/crates/qbz-qt/src/player_bridge.rs +++ b/crates/qbz-qt/src/player_bridge.rs @@ -200,6 +200,12 @@ pub mod qbz_player { /// Track-row click (Library): play the track as a 1-element queue. #[qinvokable] fn play_track(self: Pin<&mut QbzPlayer>, track_id: QString); + /// Play one track KNOWING where the row came from ("qobuz" | "local" | + /// "plex"; empty = qobuz). Use this from any rail whose rows can be + /// non-Qobuz — `play_track` alone only has an id, and an id is not + /// enough to tell a library rowid from a Qobuz track id. + #[qinvokable] + fn play_track_from(self: Pin<&mut QbzPlayer>, track_id: QString, source: QString); /// Library track context menus: Play next ("next") / Play later /// ("later") / Add to queue ("queue") on a single feed track. #[qinvokable] @@ -450,6 +456,12 @@ impl qbz_player::QbzPlayer { } } + pub fn play_track_from(self: Pin<&mut Self>, track_id: QString, source: QString) { + if let Ok(id) = track_id.to_string().parse::() { + crate::play_track_from(id, source.to_string()); + } + } + pub fn enqueue_track(self: Pin<&mut Self>, track_id: QString, mode: QString) { if let Ok(id) = track_id.to_string().parse::() { crate::enqueue_track(id, mode.to_string()); diff --git a/crates/qbz-qt/src/recently_qt.rs b/crates/qbz-qt/src/recently_qt.rs index 3aacdfb2e..8e6e2f82f 100644 --- a/crates/qbz-qt/src/recently_qt.rs +++ b/crates/qbz-qt/src/recently_qt.rs @@ -61,6 +61,14 @@ pub struct RecentTrack { pub source: String, } +/// One history entry by track id. The store is a short JSON list (24 rows +/// reach the rail), so a linear scan is cheaper than any index — and this is +/// the ONLY place that knows a non-Qobuz row's album key, which is what the +/// Plex cache is queried by. +pub fn find_track(id: &str) -> Option { + load_tracks().into_iter().find(|t| t.id == id) +} + /// One recently-played album (its own history since #567). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct RecentAlbum { diff --git a/crates/qbz-qt/src/search_qt.rs b/crates/qbz-qt/src/search_qt.rs index 07a346349..12ae46685 100644 --- a/crates/qbz-qt/src/search_qt.rs +++ b/crates/qbz-qt/src/search_qt.rs @@ -1032,15 +1032,41 @@ fn flat_index_content_y(data: &CortinillaData, flat_index: i32) -> f64 { /// ids are in a different space from the catalog's, and a local artist row /// has no id at all, so a bucket keyed on them could never be matched again. pub fn row_clicked(flat_index: i32) { + // TRACED, because this router had THREE ways to do nothing without saying + // so — no snapshot, no matching flat index, and an unhandled kind — and the + // owner reported a local row whose click produced no effect AND no log + // line. A silent no-op is indistinguishable from a dead signal from the + // outside; whichever of these fires, the log now names it. let snap = LAST_CORT.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let Some(data) = snap else { return }; + let Some(data) = snap else { + log::warn!("[qbz-qt] cortinilla click {flat_index}: NO SNAPSHOT (LAST_CORT is None)"); + return; + }; let row = data .top .iter() .chain(data.sections.iter().flat_map(|s| s.rows.iter())) .find(|r| r.flat_index == flat_index) .cloned(); - let Some(row) = row else { return }; + let Some(row) = row else { + let have: Vec = data + .top + .iter() + .chain(data.sections.iter().flat_map(|s| s.rows.iter())) + .map(|r| r.flat_index) + .collect(); + log::warn!( + "[qbz-qt] cortinilla click {flat_index}: NO ROW with that flat index — \ + snapshot holds {have:?}" + ); + return; + }; + log::info!( + "[qbz-qt] cortinilla click {flat_index}: kind={} source={} id={}", + row.kind, + row.source, + row.id + ); set_cortinilla_open(false); if row.source != "local" { let action = if row.kind == "track" { @@ -1057,12 +1083,31 @@ pub fn row_clicked(flat_index: i32) { // album that happens to share the number. if row.source == "local" { match row.kind.as_str() { - // The group key opens the local album view directly. - "album" => crate::local_bridge::open_album_by_id(row.id), - // BY NAME — local artists have no id (search_local.rs). - "artist" => crate::local_album_actions::open_artist_by_name(row.title), + // Through the SHARED album router, not straight to the loader. + // `open_album_by_id` only publishes the document — it does not + // navigate — so calling it alone loaded the album into a view that + // was never shown: the click did nothing and logged nothing + // (owner report, 2026-08-13, on `plex:4868...`). `crate::open_album` + // is the one place that pairs the load with `nav_qt::record` + + // `set_current_view`, and its `is_local_feed_id` arm already + // recognises both `plex:` keys and path-shaped local group keys. + "album" => crate::open_album(row.id), + // BY NAME — local artists have no id (search_local.rs). Same shape + // as the album row: `open_artist_by_name` only parks the name in + // `local_pending_artist` for the Artists tab to consume, so the + // view has to be brought up for anything to consume it. + "artist" => { + crate::nav_qt::record("local"); + crate::shell_bridge::ui(|mut b| { + b.as_mut() + .set_current_view(QString::from("local")) + }); + crate::local_album_actions::open_artist_by_name(row.title); + } "track" => play_local_row(&row.id), - _ => {} + other => log::warn!( + "[qbz-qt] cortinilla click: local row kind {other:?} has no route — nothing done" + ), } return; } diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 5beb0c27d..bf0adb3d1 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -1685,6 +1685,12 @@ fn backend_label(t: AudioBackendType) -> String { pub async fn publish_snapshot() { let audio_settings = audio_settings(); let prefs = with_playback(|s| s.get_preferences()).unwrap_or_default(); + // Re-seed the persistence gates from what we just read. `init_for_user` + // seeds them at login and the two toggles push them live, so this only + // matters when the prefs changed OUTSIDE this process — the Slint build + // writes the same per-user DB. Cheap, and it makes the gates self-healing + // instead of trusting that nothing else ever touches the file. + qbz_app::session_persist::set_gates(prefs.persist_session, prefs.resume_playback_position); let streaming_key = streaming_quality(); // The now-playing stamp's two output LEDs are a pure function of the @@ -2062,10 +2068,17 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, r } "persist-session" => { - with_playback(|s| s.set_persist_session(value)).map(|_| Apply::None) + // Push the LIVE gate too, not just the stored bool: capture/restore + // read the gate, so persisting alone left the toggle inert until a + // restart — the "renders, persists, drives nothing" class again. + let r = with_playback(|s| s.set_persist_session(value)).map(|_| Apply::None); + qbz_app::session_persist::set_persist_gate(value); + r } "resume-position" => { - with_playback(|s| s.set_resume_playback_position(value)).map(|_| Apply::None) + let r = with_playback(|s| s.set_resume_playback_position(value)).map(|_| Apply::None); + qbz_app::session_persist::set_resume_gate(value); + r } // --- Appearance (phase 19): plain ui_prefs bools (+ live bridge // side-effects where the POC already has the consumer). diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 22333c9e7..5bd4da966 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -721,7 +721,13 @@ impl Default for QbzShellRust { nav_header_compact: crate::settings_qt::nav_header_compact(), sidebar_playlist_collage: crate::settings_qt::sidebar_playlist_collage(), queue_open: false, - current_view: QString::from("home"), + // The persisted startup page (Settings > Appearance > Startup + // page). Resolved at CONSTRUCTION so the first mounted view is + // already the right one — routing after the fact would flash Home + // and then swap, which is worse than not restoring at all. + // `startup_view()` returns "home" unless the pref says "remember" + // AND the stored view is in the safe set. + current_view: QString::from(crate::nav_qt::startup_view().as_str()), can_back: false, nav_tab: QString::default(), nav_tab_view: QString::default(),