From 33ffb94083c4952d335564c80e068cd99a71cda1 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sun, 17 May 2026 19:54:38 +0300 Subject: [PATCH] fix(isomp4): fix M4A moov-at-end probe failures and streaming fallback (#757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stream): defer M4A probe until moov tail or fast-start prefix Ranged moov-at-end M4A started Symphonia format probe as soon as ~384 KiB linear data arrived, before the parallel tail prefetch filled the moov atom — probe hit end of stream and skipped the track. Wait for tail_ready or detect fast-start moov in the prefix; do not arm playback from linear bytes alone when tail prefetch is active. * fix(isomp4): skip EOF-spanning mdat after moov-at-end is parsed Second-pass header scan with moov already loaded still tried to read through mdat→EOF on RangedHttpSource holes, causing format probe end of stream. Re-check play generation after moov wait. * fix(isomp4): fix AtomIterator overread after seek in patched demuxer `AtomIterator::new_root(reader, len)` treats `len` as bytes available from the current `reader.pos()`, not the absolute file length. After `mss.seek(resume_at)` we were passing the absolute `total_len`, so the iterator thought there were `total_len` bytes left and tried to read past EOF on the next iteration, returning "end of stream". Fix: pass `total_len.map(|tl| tl.saturating_sub(resume_at))` (remaining bytes from the new position) in both branches: - `moov.is_none()` (moov-at-end layout, seek to moov offset) - `moov.is_some()` (fast-start layout, skip bounded mdat body) This caused Symphonia to fail probing moov-at-end M4A files read from local disk (hot-cache) and from in-memory buffers — every decode attempt returned "end of stream", analysis fell back to `byte_envelope_no_ebu` (no EBU R128 loudness), and rodio produced distorted audio. Also in this commit: - `resolve_playback_format_hint()` helper to resolve hint from URL, stream suffix, Content-Disposition, or byte sniff - ISO-BMFF diagnostic helpers (`isobmff_buffer_looks_complete`, `log_isobmff_buffer_diagnostic`, `mp4_suspect_zero_holes`) - Probe-fallback path for ranged-stream failures now uses these helpers to decide whether to refetch or wait for the in-flight download * chore(audio): remove redundant hint recomputation and add missing blank line `bytes_hint_for_wait` in the ranged-stream fallback path was an exact duplicate of `effective_hint` already in scope — reuse the existing binding. Also add missing blank line after `wait_for_ranged_mp4_probe_ready` in mod.rs. * chore(release): CHANGELOG and credits for PR #757 Add Fixed entry for M4A moov-at-end probe fix and credits line in settingsCredits.ts under existing cucadmuh contributions. * fix(audio): extract BuildSourceArgs to fix clippy::too_many_arguments `build_playback_source_with_probe_fallback` had 12 parameters, exceeding the clippy limit of 7. Group url/gen/hints/fade/hi-res/duration into `BuildSourceArgs` so the function signature stays at 4 arguments. --- CHANGELOG.md | 8 + .../crates/psysonic-audio/src/commands.rs | 24 +- .../crates/psysonic-audio/src/helpers.rs | 40 +++ .../crates/psysonic-audio/src/play_input.rs | 305 +++++++++++++++++- .../crates/psysonic-audio/src/stream/mod.rs | 55 ++++ .../crates/psysonic-audio/src/stream/mp4.rs | 104 ++++++ .../psysonic-audio/src/stream/ranged_http.rs | 38 ++- .../symphonia-format-isomp4/src/demuxer.rs | 23 +- src/config/settingsCredits.ts | 1 + 9 files changed, 571 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de2c9a04..9c87d2a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -663,6 +663,14 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * Selecting an empty library no longer leaves these pages as a fully blank canvas. A shared `common.libraryEmpty` message ("Your library is empty.") added across all nine locales is shown in place of the empty rails/grids. Pages that already had a dedicated empty-state (Artists, Genres, Composers, Playlists, Favorites, Most Played, Lossless Albums, Label Albums, Internet Radio) keep their per-page wording. On Albums and New Releases, an active genre / year / starred / compilation filter still shows the regular filtered-results behaviour rather than the library-empty message. +### M4A playback — probe failures and distorted audio on moov-at-end files + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#757](https://github.com/Psychotoxical/psysonic/pull/757)** + +* Fixed a bug in the patched `symphonia-format-isomp4` demuxer that caused Symphonia to fail probing every M4A file read from the hot-cache (in-memory bytes) or from a local file after playing it once. `AtomIterator::new_root` treats `len` as bytes available from the current reader position, not the absolute file length; after `seek(offset)` the code was passing `total_len` (absolute) instead of `total_len − offset` (remaining), so the iterator overread past EOF and returned `end of stream`. Loudness analysis fell back to `byte_envelope_no_ebu` (no EBU R128 gain applied) and the audio was decoded with distortion. Both affected branches — moov-at-end layout and fast-start mdat skip — now receive the correct remaining length. +* Ranged streaming of moov-at-end M4A files no longer races Symphonia's probe against the tail prefetch: a `RangedMp4ProbeGate` holds the probe thread until either the moov tail fetch completes or moov is confirmed in the already-downloaded prefix (fast-start). Previously the probe could start on a partial buffer and immediately return `format probe failed: end of stream`, falling through to the sequential-download fallback even when the track would have played fine. +* The full-download fallback path now runs ISO-BMFF diagnostic checks on the completed ranged buffer before handing it to Symphonia: zero-hole detection (`mp4_suspect_zero_holes`) and completeness gating (`isobmff_buffer_looks_complete`) trigger an automatic HTTP refetch when the buffer looks sparse or structurally incomplete. + ## [1.45.0] - 2026-05-04 ## Added diff --git a/src-tauri/crates/psysonic-audio/src/commands.rs b/src-tauri/crates/psysonic-audio/src/commands.rs index 4947e045..d43e0892 100644 --- a/src-tauri/crates/psysonic-audio/src/commands.rs +++ b/src-tauri/crates/psysonic-audio/src/commands.rs @@ -15,8 +15,9 @@ use super::engine::{audio_http_client, AudioEngine}; use super::helpers::*; use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload}; use super::play_input::{ - build_source_from_play_input, select_play_input, spawn_legacy_stream_start_when_armed, - swap_in_new_sink, url_format_hint, PlayInputContext, SinkSwapInputs, + build_playback_source_with_probe_fallback, select_play_input, + spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs, + PlayInputContext, SinkSwapInputs, }; use super::preview::preview_clear_for_new_main_playback; use super::progress_task::spawn_progress_task; @@ -232,14 +233,21 @@ pub async fn audio_play( let done_flag = Arc::new(AtomicBool::new(false)); // Reset sample counter for the new track. state.samples_played.store(0, Ordering::Relaxed); - let playback_source = build_source_from_play_input( + let playback_source = build_playback_source_with_probe_fallback( play_input, + BuildSourceArgs { + url: &url, + gen, + cache_id_for_tasks: cache_id_for_tasks.as_deref(), + url_format_hint: format_hint.as_deref(), + stream_format_suffix: stream_format_suffix.as_deref(), + done_flag: done_flag.clone(), + fade_in_dur, + hi_res_enabled, + duration_hint, + }, &state, - format_hint.as_deref(), - done_flag.clone(), - fade_in_dur, - hi_res_enabled, - duration_hint, + &app, ) .await .map_err(|e| { diff --git a/src-tauri/crates/psysonic-audio/src/helpers.rs b/src-tauri/crates/psysonic-audio/src/helpers.rs index 0b30fe19..12bd6255 100644 --- a/src-tauri/crates/psysonic-audio/src/helpers.rs +++ b/src-tauri/crates/psysonic-audio/src/helpers.rs @@ -153,6 +153,21 @@ pub(crate) fn format_hint_from_content_disposition(cd: &str) -> Option { None } +/// Best Symphonia container hint for playback: ranged/stream media hint, URL tail, +/// Subsonic `song.suffix`, then magic-byte sniff on buffered bytes. +pub(crate) fn resolve_playback_format_hint( + url_hint: Option<&str>, + stream_suffix: Option<&str>, + media_hint: Option<&str>, + data: Option<&[u8]>, +) -> Option { + media_hint + .map(str::to_string) + .or_else(|| url_hint.map(str::to_string)) + .or_else(|| normalize_stream_suffix_for_hint(stream_suffix)) + .or_else(|| data.and_then(sniff_stream_format_extension)) +} + /// Subsonic [`song.suffix`](https://www.subsonic.org/pages/api.jsp#getSong) — stream.view URLs /// usually have no file extension; this supplies `format_hint` for ranged open. pub(crate) fn normalize_stream_suffix_for_hint(suffix: Option<&str>) -> Option { @@ -1026,6 +1041,31 @@ mod tests { assert_eq!(format_hint_from_content_disposition("inline"), None); } + // ── resolve_playback_format_hint ─────────────────────────────────────────── + + #[test] + fn resolve_playback_hint_prefers_media_then_suffix() { + assert_eq!( + resolve_playback_format_hint(None, Some("m4a"), Some("flac"), None), + Some("flac".into()), + ); + assert_eq!( + resolve_playback_format_hint(None, Some("m4a"), None, None), + Some("m4a".into()), + ); + } + + #[test] + fn resolve_playback_hint_sniffs_bytes_when_no_suffix() { + let mut buf = vec![0u8; 4]; + buf.extend_from_slice(b"ftyp"); + buf.extend_from_slice(b"M4A \x00\x00\x02\x00"); + assert_eq!( + resolve_playback_format_hint(None, None, None, Some(&buf)), + Some("m4a".into()), + ); + } + // ── normalize_stream_suffix_for_hint ────────────────────────────────────── #[test] diff --git a/src-tauri/crates/psysonic-audio/src/play_input.rs b/src-tauri/crates/psysonic-audio/src/play_input.rs index febf8e1f..5a753428 100644 --- a/src-tauri/crates/psysonic-audio/src/play_input.rs +++ b/src-tauri/crates/psysonic-audio/src/play_input.rs @@ -15,7 +15,7 @@ use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDeco use super::engine::{audio_http_client, AudioEngine}; use super::helpers::{ content_type_to_hint, fetch_data, format_hint_from_content_disposition, - normalize_stream_suffix_for_hint, sniff_stream_format_extension, + normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension, spawn_analysis_seed_from_in_memory_bytes, same_playback_target, STREAM_FORMAT_SNIFF_PROBE_BYTES, }; @@ -37,6 +37,8 @@ pub(super) enum PlayInput { reader: Box, format_hint: Option, tag: &'static str, + /// When set, Symphonia probe waits for moov (tail or fast-start prefix). + mp4_probe_gate: Option, }, Streaming { reader: AudioStreamReader, @@ -192,6 +194,7 @@ fn open_local_file_input( reader: Box::new(reader), format_hint: local_hint, tag: "local-file", + mp4_probe_gate: None, }) } @@ -270,10 +273,6 @@ async fn open_ranged_or_streaming_input( } } - // Guardrail: when format/container hint is unknown, some demuxers may - // seek near EOF during probe. With a progressively downloaded ranged - // source that can delay first audible samples until most/all bytes are - // fetched. Prefer sequential streaming in that case for faster start. if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) { let total_usize = total as usize; crate::app_deprintln!( @@ -288,6 +287,16 @@ async fn open_ranged_or_streaming_input( let playback_armed = state.stream_playback_armed.clone(); let tail_ready = Arc::new(AtomicBool::new(false)); let tail_filled_from = Arc::new(AtomicU64::new(0)); + let tail_prefetch = + super::stream::mp4_needs_tail_prefetch(&[], stream_hint.as_deref()); + let mp4_probe_gate = tail_prefetch.then(|| super::stream::RangedMp4ProbeGate { + tail_ready: tail_ready.clone(), + buf: buf.clone(), + downloaded_to: downloaded_to.clone(), + gen_arc: state.generation.clone(), + gen: ctx.gen, + format_hint: stream_hint.clone(), + }); let loudness_hold_for_defer = (total_usize <= super::stream::TRACK_STREAM_PROMOTE_MAX_BYTES) .then_some(state.ranged_loudness_seed_hold.clone()); tokio::spawn(ranged_download_task( @@ -328,6 +337,7 @@ async fn open_ranged_or_streaming_input( reader: Box::new(reader), format_hint: stream_hint, tag: "ranged-stream", + mp4_probe_gate, })); } @@ -441,6 +451,22 @@ pub(super) fn url_format_hint(url: &str) -> Option { .map(|s| s.to_lowercase()) } +/// Arguments forwarded from `audio_play` into the source-build pipeline. +/// Bundles the format-hint inputs, playback-shaping parameters and the shared +/// done flag so that `build_playback_source_with_probe_fallback` stays below +/// the `clippy::too_many_arguments` threshold. +pub(super) struct BuildSourceArgs<'a> { + pub url: &'a str, + pub gen: u64, + pub cache_id_for_tasks: Option<&'a str>, + pub url_format_hint: Option<&'a str>, + pub stream_format_suffix: Option<&'a str>, + pub done_flag: Arc, + pub fade_in_dur: Duration, + pub hi_res_enabled: bool, + pub duration_hint: f64, +} + /// Output of `build_source_from_play_input`: the wrapped rodio source plus /// whether the chosen source path is seekable (only the Streaming variant /// is not). @@ -527,6 +553,262 @@ pub(super) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI } } +fn play_media_format_hint(input: &PlayInput) -> Option { + match input { + PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => { + format_hint.clone() + } + PlayInput::Bytes(_) => None, + } +} + +/// Ranged HTTP probe/decode failed in a way that may succeed after the +/// background download finishes (moov-at-end, demuxer EOF during partial buffer). +fn is_ranged_stream_probe_failure(err: &str) -> bool { + err.contains("ranged-stream") + && (err.contains("format probe failed") + || err.contains("moov metadata") + || err.contains("end of stream")) +} + +/// Completed ranged download or spill file for `url`, if ready. +async fn try_take_completed_stream_bytes( + url: &str, + state: &State<'_, AudioEngine>, +) -> Option> { + if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) { + return Some(data); + } + let spill_path = { + let guard = state.stream_completed_spill.lock().unwrap(); + guard + .as_ref() + .filter(|p| same_playback_target(&p.url, url)) + .map(|p| p.path.clone()) + }; + if let Some(path) = spill_path { + let data = tokio::fs::read(&path).await.ok()?; + if !data.is_empty() { + return Some(data); + } + } + None +} + +/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body. +async fn prefer_clean_http_bytes_for_fallback( + url: &str, + gen: u64, + state: &State<'_, AudioEngine>, + app: &AppHandle, + ranged_data: Vec, + format_hint: Option<&str>, + label: &str, +) -> Result>, String> { + let is_mp4 = super::stream::container_hint_is_mp4(format_hint); + if is_mp4 { + super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label); + if !super::stream::isobmff_buffer_looks_complete(&ranged_data) + || super::stream::mp4_suspect_zero_holes(&ranged_data) + { + crate::app_deprintln!( + "[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP" + ); + if let Some(fresh) = fetch_data(url, state, gen, app).await? { + if super::stream::isobmff_buffer_looks_complete(&fresh) { + return Ok(Some(fresh)); + } + super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch"); + } + } + } + Ok(Some(ranged_data)) +} + +/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed. +pub(super) async fn wait_or_fetch_bytes_for_stream_fallback( + url: &str, + gen: u64, + state: &State<'_, AudioEngine>, + app: &AppHandle, + format_hint: Option<&str>, +) -> Result>, String> { + use std::time::{Duration, Instant}; + + let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS); + loop { + if state.generation.load(Ordering::SeqCst) != gen { + return Ok(None); + } + if let Some(data) = try_take_completed_stream_bytes(url, state).await { + crate::app_deprintln!( + "[stream] full-buffer fallback: using completed download ({} KiB)", + data.len() / 1024 + ); + return prefer_clean_http_bytes_for_fallback( + url, + gen, + state, + app, + data, + format_hint, + "ranged-cache", + ) + .await; + } + if Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + crate::app_deprintln!( + "[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch", + TRACK_READ_TIMEOUT_SECS + ); + fetch_data(url, state, gen, app).await +} + +fn is_in_memory_probe_failure(err: &str) -> bool { + err.contains("format probe failed") + || err.contains("could not open audio stream") + || err.contains("no playable audio track") +} + +/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits +/// for a full download (or fetches it) and retries from in-memory bytes. +pub(super) async fn build_playback_source_with_probe_fallback( + play_input: PlayInput, + args: BuildSourceArgs<'_>, + state: &State<'_, AudioEngine>, + app: &AppHandle, +) -> Result { + let BuildSourceArgs { + url, + gen, + cache_id_for_tasks, + url_format_hint, + stream_format_suffix, + done_flag, + fade_in_dur, + hi_res_enabled, + duration_hint, + } = args; + let media_hint = play_media_format_hint(&play_input); + let effective_hint = resolve_playback_format_hint( + url_format_hint, + stream_format_suffix, + media_hint.as_deref(), + None, + ); + if let Some(ref h) = effective_hint { + crate::app_deprintln!("[stream] playback format hint: {h}"); + } + + match build_source_from_play_input( + play_input, + state, + effective_hint.as_deref(), + done_flag.clone(), + fade_in_dur, + hi_res_enabled, + duration_hint, + ) + .await + { + Ok(p) => Ok(p), + Err(e) if is_ranged_stream_probe_failure(&e) => { + crate::app_deprintln!( + "[stream] ranged-stream probe failed — trying full-buffer fallback: {}", + e + ); + let data = match wait_or_fetch_bytes_for_stream_fallback( + url, + gen, + state, + app, + effective_hint.as_deref(), + ) + .await? + { + Some(d) => d, + None => return Err(e), + }; + if state.generation.load(Ordering::SeqCst) != gen { + return Err("ranged-stream: superseded during full-buffer fallback".into()); + } + let bytes_hint = resolve_playback_format_hint( + url_format_hint, + stream_format_suffix, + media_hint.as_deref(), + Some(&data), + ); + if bytes_hint.as_ref() != effective_hint.as_ref() { + crate::app_deprintln!( + "[stream] full-buffer fallback: resolved hint {:?} (was {:?})", + bytes_hint, + effective_hint + ); + } + spawn_analysis_seed_from_in_memory_bytes( + app, + cache_id_for_tasks, + gen, + &state.generation, + &data, + ); + match build_source_from_play_input( + PlayInput::Bytes(data.clone()), + state, + bytes_hint.as_deref(), + done_flag.clone(), + fade_in_dur, + hi_res_enabled, + duration_hint, + ) + .await + { + Ok(p) => Ok(p), + Err(pe) if is_in_memory_probe_failure(&pe) => { + if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) { + super::stream::log_isobmff_buffer_diagnostic( + &data, + bytes_hint.as_deref(), + "ranged-cache-probe-fail", + ); + } + crate::app_deprintln!( + "[stream] in-memory probe failed — sequential HTTP refetch: {}", + pe + ); + let fresh = match fetch_data(url, state, gen, app).await? { + Some(d) => d, + None => return Err(pe), + }; + if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) { + super::stream::log_isobmff_buffer_diagnostic( + &fresh, + bytes_hint.as_deref(), + "http-refetch-after-probe-fail", + ); + } + build_source_from_play_input( + PlayInput::Bytes(fresh), + state, + bytes_hint.as_deref(), + done_flag, + fade_in_dur, + hi_res_enabled, + duration_hint, + ) + .await + } + Err(pe) => Err(pe), + } + } + Err(e) => Err(e), + } +} + /// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full /// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming /// the streaming variant runs the decoder build on a blocking thread. @@ -557,7 +839,18 @@ pub(super) async fn build_source_from_play_input( format_hint, hi_res_enabled, ), - PlayInput::SeekableMedia { reader, format_hint: media_hint, tag } => { + PlayInput::SeekableMedia { + reader, + format_hint: media_hint, + tag, + mp4_probe_gate, + } => { + if let Some(gate) = mp4_probe_gate.as_ref() { + super::stream::wait_for_ranged_mp4_probe_ready(gate).await?; + if gate.gen_arc.load(Ordering::SeqCst) != gate.gen { + return Err("ranged-stream: superseded before moov metadata ready".into()); + } + } let decoder = tokio::task::spawn_blocking(move || { SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag) }) diff --git a/src-tauri/crates/psysonic-audio/src/stream/mod.rs b/src-tauri/crates/psysonic-audio/src/stream/mod.rs index e2417b3e..412aee5c 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/mod.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/mod.rs @@ -17,6 +17,10 @@ mod ranged_http; mod reader; mod track_stream; +pub(crate) use mp4::{ + container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic, + mp4_needs_tail_prefetch, mp4_suspect_zero_holes, +}; pub(crate) use local_file::LocalFileSource; pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task}; pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task}; @@ -65,5 +69,56 @@ pub(crate) fn maybe_arm_stream_playback(downloaded: u64, playback_armed: &std::s ); } } + +/// Held until `RangedHttpSource` has moov metadata for Symphonia probe (tail prefetch +/// or fast-start moov in the linear prefix). +pub(crate) struct RangedMp4ProbeGate { + pub(crate) tail_ready: std::sync::Arc, + pub(crate) buf: std::sync::Arc>>, + pub(crate) downloaded_to: std::sync::Arc, + pub(crate) gen_arc: std::sync::Arc, + pub(crate) gen: u64, + pub(crate) format_hint: Option, +} + +/// Block until moov is reachable: tail prefetch completed or moov already in the +/// downloaded prefix (fast-start). Avoids Symphonia probing moov-at-end M4A before +/// the tail range is filled (format probe failed: end of stream). +pub(crate) async fn wait_for_ranged_mp4_probe_ready(gate: &RangedMp4ProbeGate) -> Result<(), String> { + use std::sync::atomic::Ordering; + use std::time::{Duration, Instant}; + + const PREFIX_SCAN_MIN: usize = 64 * 1024; + let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS); + + loop { + if gate.gen_arc.load(Ordering::SeqCst) != gate.gen { + return Err("ranged-stream: superseded before moov metadata ready".into()); + } + if gate.tail_ready.load(Ordering::Relaxed) { + crate::app_deprintln!("[stream] ranged: moov metadata ready (tail prefetch)"); + return Ok(()); + } + let dl = gate.downloaded_to.load(Ordering::Relaxed); + if dl >= PREFIX_SCAN_MIN { + let guard = gate.buf.lock().unwrap(); + let n = dl.min(guard.len()); + if !mp4::mp4_needs_tail_prefetch(&guard[..n], gate.format_hint.as_deref()) { + crate::app_deprintln!( + "[stream] ranged: moov metadata ready (fast-start, {} KiB prefix)", + n / 1024 + ); + return Ok(()); + } + } + if Instant::now() >= deadline { + return Err( + "ranged-stream: timed out waiting for moov metadata (tail prefetch)".into(), + ); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + /// Sleep interval when ring buffer is empty (prevents CPU spin). pub(crate) const RADIO_YIELD_MS: u64 = 2; diff --git a/src-tauri/crates/psysonic-audio/src/stream/mp4.rs b/src-tauri/crates/psysonic-audio/src/stream/mp4.rs index a46c2e2e..a7afbf07 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/mp4.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/mp4.rs @@ -78,6 +78,92 @@ pub(crate) fn mp4_needs_tail_prefetch(prefix: &[u8], hint: Option<&str>) -> bool saw_mdat && !saw_moov } +/// Scan `[scan_start, scan_end)` for a top-level atom fourcc (e.g. `moov`). +fn find_atom_fourcc(data: &[u8], atom: &[u8; 4], scan_start: usize, scan_end: usize) -> Option { + let end = scan_end.min(data.len()); + let start = scan_start.min(end); + for i in start..end.saturating_sub(8) { + if data[i + 4..i + 8] == *atom { + return Some(i); + } + } + None +} + +/// `moov` in the last 8 MiB (moov-at-end) or anywhere in the first 32 MiB (fast-start). +pub(crate) fn mp4_has_moov_atom(data: &[u8]) -> bool { + if data.len() < 16 { + return false; + } + const TAIL_SCAN: usize = 8 * 1024 * 1024; + const PREFIX_SCAN: usize = 32 * 1024 * 1024; + if find_atom_fourcc(data, b"moov", data.len().saturating_sub(TAIL_SCAN), data.len()).is_some() { + return true; + } + find_atom_fourcc(data, b"moov", 0, PREFIX_SCAN.min(data.len())).is_some() +} + +/// `ftyp` in the first few KB — minimal sanity check for ISO-BMFF from ranged assembly. +pub(crate) fn mp4_has_ftyp_atom(data: &[u8]) -> bool { + find_atom_fourcc(data, b"ftyp", 0, data.len().min(8192)).is_some() +} + +/// After a full ranged download, the buffer should contain `ftyp` and `moov`. +pub(crate) fn isobmff_buffer_looks_complete(data: &[u8]) -> bool { + mp4_has_ftyp_atom(data) && mp4_has_moov_atom(data) +} + +/// Heuristic: large runs of zero bytes between `ftyp` and `moov` suggest a sparse/holey +/// ranged buffer (tail prefetch + incomplete linear fill) rather than real audio. +pub(crate) fn mp4_suspect_zero_holes(data: &[u8]) -> bool { + if data.len() < 256 * 1024 { + return false; + } + let moov_off = find_atom_fourcc(data, b"moov", data.len().saturating_sub(8 * 1024 * 1024), data.len()) + .or_else(|| find_atom_fourcc(data, b"moov", 0, data.len().min(32 * 1024 * 1024))); + let Some(moov_off) = moov_off else { + return false; + }; + let scan_end = moov_off.min(data.len()); + let scan_start = 64 * 1024; // skip tiny header + if scan_end <= scan_start + 64 * 1024 { + return false; + } + const BLOCK: usize = 64 * 1024; + let mut zero_blocks = 0usize; + let mut total_blocks = 0usize; + let mut pos = scan_start; + while pos + BLOCK <= scan_end { + total_blocks += 1; + if data[pos..pos + BLOCK].iter().all(|&b| b == 0) { + zero_blocks += 1; + } + pos += BLOCK; + } + total_blocks > 4 && zero_blocks * 100 / total_blocks >= 10 +} + +/// Log why Symphonia may reject a buffer (for support / debugging). +pub(crate) fn log_isobmff_buffer_diagnostic(data: &[u8], hint: Option<&str>, label: &str) { + let prefix_hex: String = data + .iter() + .take(16) + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" "); + let looks_json = data.starts_with(b"{") || data.starts_with(b"["); + crate::app_eprintln!( + "[stream] ISO-BMFF diagnostic ({label}): hint={:?} bytes={} prefix=[{}] json_like={} ftyp={} moov={} zero_holes={}", + hint, + data.len(), + prefix_hex, + looks_json, + mp4_has_ftyp_atom(data), + mp4_has_moov_atom(data), + mp4_suspect_zero_holes(data), + ); +} + fn read_mp4_atom_size(data: &[u8], pos: usize) -> Option { if pos + 8 > data.len() { return None; @@ -136,4 +222,22 @@ mod tests { assert!(!mp4_needs_tail_prefetch(&[], Some("mp3"))); assert!(!mp4_needs_tail_prefetch(&[], None)); } + + #[test] + fn isobmff_complete_detects_ftyp_and_moov() { + let mut buf = Vec::new(); + buf.extend(atom(b"ftyp", 4)); + buf.extend(atom(b"mdat", 200)); + buf.extend(atom(b"moov", 40)); + assert!(isobmff_buffer_looks_complete(&buf)); + assert!(!mp4_suspect_zero_holes(&buf)); + } + + #[test] + fn isobmff_incomplete_without_moov() { + let mut buf = Vec::new(); + buf.extend(atom(b"ftyp", 4)); + buf.extend(atom(b"mdat", 200)); + assert!(!isobmff_buffer_looks_complete(&buf)); + } } diff --git a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs index 02aecc8a..06ecbef8 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs @@ -200,13 +200,6 @@ pub(crate) enum RangedHttpLoopOutcome { Aborted, } -/// Pure HTTP loop: reads from `initial_response` (and reconnects on transient -/// errors via `Range:` requests against `http_client`) until either `total_size` -/// bytes have been written into `buf`, the generation flips, or the reconnect -/// budget is exhausted. No `tauri::AppHandle` dependency — partial-progress -/// notifications go through `on_partial`, which the caller wires up with its -/// own emitter (or a no-op in tests). -/// /// Returns `(downloaded_bytes, outcome)`. The caller is responsible for setting /// any `done` flag, promoting the buffer to a cache, or kicking off analysis /// seeding once the loop returns. @@ -426,7 +419,13 @@ async fn ranged_prefetch_mp4_tail( Ok(written) if written > 0 => { tail_filled_from.store(tail_from, Ordering::Relaxed); tail_ready.store(true, Ordering::SeqCst); - super::maybe_arm_stream_playback(tail_from + written as u64, &playback_armed); + if !playback_armed.load(Ordering::Relaxed) { + playback_armed.store(true, Ordering::SeqCst); + crate::app_deprintln!( + "[stream] playback armed after moov tail prefetch ({} KiB)", + written / 1024 + ); + } crate::app_deprintln!( "[stream] ranged: moov-at-end tail prefetch {} KiB (from byte {})", written / 1024, @@ -562,6 +561,11 @@ pub(crate) async fn ranged_download_task( None }; + let linear_arm = if tail_prefetch { + None + } else { + Some(playback_armed.as_ref()) + }; let (downloaded, outcome) = ranged_http_download_loop( http_client, &url, @@ -571,7 +575,7 @@ pub(crate) async fn ranged_download_task( gen, &gen_arc, on_partial, - Some(&playback_armed), + linear_arm, ) .await; @@ -605,6 +609,22 @@ pub(crate) async fn ranged_download_task( if downloaded == total_size && total_size > 0 { if total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES { + if super::container_hint_is_mp4(format_hint.as_deref()) { + let guard = buf.lock().unwrap(); + if !super::isobmff_buffer_looks_complete(&guard) { + super::log_isobmff_buffer_diagnostic( + &guard, + format_hint.as_deref(), + "ranged-dl-complete-incomplete", + ); + } else if super::mp4_suspect_zero_holes(&guard) { + super::log_isobmff_buffer_diagnostic( + &guard, + format_hint.as_deref(), + "ranged-dl-complete-zero-holes", + ); + } + } if let Some(ref tid) = cache_track_id { crate::app_deprintln!( "[stream] ranged: HTTP buffer full track_id={} size_mib={:.2} — cloning {} bytes then full-track analysis (cpu-seed queue; this task awaits completion)", diff --git a/src-tauri/patches/symphonia-format-isomp4/src/demuxer.rs b/src-tauri/patches/symphonia-format-isomp4/src/demuxer.rs index 64a311d4..54e69e7b 100644 --- a/src-tauri/patches/symphonia-format-isomp4/src/demuxer.rs +++ b/src-tauri/patches/symphonia-format-isomp4/src/demuxer.rs @@ -436,12 +436,27 @@ impl FormatReader for IsoMp4Reader { end }; mss.seek(SeekFrom::Start(resume_at))?; - iter = AtomIterator::new_root(mss, total_len); - } else if end < file_len.saturating_sub(8) { - // Fast-start: skip a bounded mdat without linear read. + // `AtomIterator::new_root` treats `len` as bytes available **from the + // current reader position**, not the absolute file length. Passing + // `total_len` here makes the iterator think the file is `total_len` + // bytes long after the seek, so reading the last atom's trailer + // (e.g. a tail `moov`) succeeds, but the next `iter.next()` then keeps + // reading past EOF and returns `end of stream`. Pass the **remaining** + // length instead so the iterator stops cleanly when the last atom ends. + let remaining = total_len.map(|tl| tl.saturating_sub(resume_at)); + iter = AtomIterator::new_root(mss, remaining); + } else if moov.is_some() { + // `moov` was already parsed (fast-start layout). Skip the `mdat` body + // without linear-reading it (holes in RangedHttpSource). Never seek to + // `file_len` — that is one byte past the last valid offset and makes the + // next atom read return end-of-stream on in-memory sources. + if end >= file_len.saturating_sub(8) { + break; + } let mut mss = iter.into_inner(); mss.seek(SeekFrom::Start(end))?; - iter = AtomIterator::new_root(mss, total_len); + let remaining = total_len.map(|tl| tl.saturating_sub(end)); + iter = AtomIterator::new_root(mss, remaining); } } AtomType::Meta => { diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 2a8b86d2..3a6d2534 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -115,6 +115,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Multi-server: pin queue streams, cover art, links, context menu, and Now Playing to queue server (PR #717)', 'M4A/MP4 streaming: moov-at-end tail prefetch and Symphonia isomp4 probe fix (PR #737)', 'HTTP stream buffering — seekbar/timer at zero and cover overlay until playback arms (PR #737)', + 'M4A playback: fix AtomIterator overread in patched isomp4 demuxer — probe gate for ranged tail prefetch, zero-hole fallback detection (PR #757)', ], }, {