fix(isomp4): fix M4A moov-at-end probe failures and streaming fallback (#757)

* 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.
This commit is contained in:
cucadmuh
2026-05-17 19:54:38 +03:00
committed by GitHub
parent 6595c146a3
commit 33ffb94083
9 changed files with 571 additions and 27 deletions
+8
View File
@@ -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. * 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 ## [1.45.0] - 2026-05-04
## Added ## Added
@@ -15,8 +15,9 @@ use super::engine::{audio_http_client, AudioEngine};
use super::helpers::*; use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload}; use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{ use super::play_input::{
build_source_from_play_input, select_play_input, spawn_legacy_stream_start_when_armed, build_playback_source_with_probe_fallback, select_play_input,
swap_in_new_sink, url_format_hint, PlayInputContext, SinkSwapInputs, 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::preview::preview_clear_for_new_main_playback;
use super::progress_task::spawn_progress_task; use super::progress_task::spawn_progress_task;
@@ -232,14 +233,21 @@ pub async fn audio_play(
let done_flag = Arc::new(AtomicBool::new(false)); let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track. // Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed); 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, 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, &state,
format_hint.as_deref(), &app,
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
) )
.await .await
.map_err(|e| { .map_err(|e| {
@@ -153,6 +153,21 @@ pub(crate) fn format_hint_from_content_disposition(cd: &str) -> Option<String> {
None 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<String> {
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 /// 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. /// usually have no file extension; this supplies `format_hint` for ranged open.
pub(crate) fn normalize_stream_suffix_for_hint(suffix: Option<&str>) -> Option<String> { pub(crate) fn normalize_stream_suffix_for_hint(suffix: Option<&str>) -> Option<String> {
@@ -1026,6 +1041,31 @@ mod tests {
assert_eq!(format_hint_from_content_disposition("inline"), None); 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 ────────────────────────────────────── // ── normalize_stream_suffix_for_hint ──────────────────────────────────────
#[test] #[test]
@@ -15,7 +15,7 @@ use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDeco
use super::engine::{audio_http_client, AudioEngine}; use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{ use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition, 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, spawn_analysis_seed_from_in_memory_bytes, same_playback_target,
STREAM_FORMAT_SNIFF_PROBE_BYTES, STREAM_FORMAT_SNIFF_PROBE_BYTES,
}; };
@@ -37,6 +37,8 @@ pub(super) enum PlayInput {
reader: Box<dyn MediaSource>, reader: Box<dyn MediaSource>,
format_hint: Option<String>, format_hint: Option<String>,
tag: &'static str, tag: &'static str,
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
}, },
Streaming { Streaming {
reader: AudioStreamReader, reader: AudioStreamReader,
@@ -192,6 +194,7 @@ fn open_local_file_input(
reader: Box::new(reader), reader: Box::new(reader),
format_hint: local_hint, format_hint: local_hint,
tag: "local-file", 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()) { if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
let total_usize = total as usize; let total_usize = total as usize;
crate::app_deprintln!( crate::app_deprintln!(
@@ -288,6 +287,16 @@ async fn open_ranged_or_streaming_input(
let playback_armed = state.stream_playback_armed.clone(); let playback_armed = state.stream_playback_armed.clone();
let tail_ready = Arc::new(AtomicBool::new(false)); let tail_ready = Arc::new(AtomicBool::new(false));
let tail_filled_from = Arc::new(AtomicU64::new(0)); 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) let loudness_hold_for_defer = (total_usize <= super::stream::TRACK_STREAM_PROMOTE_MAX_BYTES)
.then_some(state.ranged_loudness_seed_hold.clone()); .then_some(state.ranged_loudness_seed_hold.clone());
tokio::spawn(ranged_download_task( tokio::spawn(ranged_download_task(
@@ -328,6 +337,7 @@ async fn open_ranged_or_streaming_input(
reader: Box::new(reader), reader: Box::new(reader),
format_hint: stream_hint, format_hint: stream_hint,
tag: "ranged-stream", tag: "ranged-stream",
mp4_probe_gate,
})); }));
} }
@@ -441,6 +451,22 @@ pub(super) fn url_format_hint(url: &str) -> Option<String> {
.map(|s| s.to_lowercase()) .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<AtomicBool>,
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 /// Output of `build_source_from_play_input`: the wrapped rodio source plus
/// whether the chosen source path is seekable (only the Streaming variant /// whether the chosen source path is seekable (only the Streaming variant
/// is not). /// 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<String> {
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<Vec<u8>> {
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<u8>,
format_hint: Option<&str>,
label: &str,
) -> Result<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<PlaybackSource, String> {
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 /// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming /// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
/// the streaming variant runs the decoder build on a blocking thread. /// 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, format_hint,
hi_res_enabled, 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 || { let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag) SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
}) })
@@ -17,6 +17,10 @@ mod ranged_http;
mod reader; mod reader;
mod track_stream; 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 local_file::LocalFileSource;
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task}; pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
pub(crate) use ranged_http::{RangedHttpSource, ranged_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<std::sync::atomic::AtomicBool>,
pub(crate) buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
pub(crate) downloaded_to: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub(crate) gen_arc: std::sync::Arc<std::sync::atomic::AtomicU64>,
pub(crate) gen: u64,
pub(crate) format_hint: Option<String>,
}
/// 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). /// Sleep interval when ring buffer is empty (prevents CPU spin).
pub(crate) const RADIO_YIELD_MS: u64 = 2; pub(crate) const RADIO_YIELD_MS: u64 = 2;
@@ -78,6 +78,92 @@ pub(crate) fn mp4_needs_tail_prefetch(prefix: &[u8], hint: Option<&str>) -> bool
saw_mdat && !saw_moov 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<usize> {
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::<Vec<_>>()
.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<u64> { fn read_mp4_atom_size(data: &[u8], pos: usize) -> Option<u64> {
if pos + 8 > data.len() { if pos + 8 > data.len() {
return None; return None;
@@ -136,4 +222,22 @@ mod tests {
assert!(!mp4_needs_tail_prefetch(&[], Some("mp3"))); assert!(!mp4_needs_tail_prefetch(&[], Some("mp3")));
assert!(!mp4_needs_tail_prefetch(&[], None)); 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));
}
} }
@@ -200,13 +200,6 @@ pub(crate) enum RangedHttpLoopOutcome {
Aborted, 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 /// Returns `(downloaded_bytes, outcome)`. The caller is responsible for setting
/// any `done` flag, promoting the buffer to a cache, or kicking off analysis /// any `done` flag, promoting the buffer to a cache, or kicking off analysis
/// seeding once the loop returns. /// seeding once the loop returns.
@@ -426,7 +419,13 @@ async fn ranged_prefetch_mp4_tail(
Ok(written) if written > 0 => { Ok(written) if written > 0 => {
tail_filled_from.store(tail_from, Ordering::Relaxed); tail_filled_from.store(tail_from, Ordering::Relaxed);
tail_ready.store(true, Ordering::SeqCst); 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!( crate::app_deprintln!(
"[stream] ranged: moov-at-end tail prefetch {} KiB (from byte {})", "[stream] ranged: moov-at-end tail prefetch {} KiB (from byte {})",
written / 1024, written / 1024,
@@ -562,6 +561,11 @@ pub(crate) async fn ranged_download_task(
None None
}; };
let linear_arm = if tail_prefetch {
None
} else {
Some(playback_armed.as_ref())
};
let (downloaded, outcome) = ranged_http_download_loop( let (downloaded, outcome) = ranged_http_download_loop(
http_client, http_client,
&url, &url,
@@ -571,7 +575,7 @@ pub(crate) async fn ranged_download_task(
gen, gen,
&gen_arc, &gen_arc,
on_partial, on_partial,
Some(&playback_armed), linear_arm,
) )
.await; .await;
@@ -605,6 +609,22 @@ pub(crate) async fn ranged_download_task(
if downloaded == total_size && total_size > 0 { if downloaded == total_size && total_size > 0 {
if total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES { 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 { if let Some(ref tid) = cache_track_id {
crate::app_deprintln!( 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)", "[stream] ranged: HTTP buffer full track_id={} size_mib={:.2} — cloning {} bytes then full-track analysis (cpu-seed queue; this task awaits completion)",
@@ -436,12 +436,27 @@ impl FormatReader for IsoMp4Reader {
end end
}; };
mss.seek(SeekFrom::Start(resume_at))?; mss.seek(SeekFrom::Start(resume_at))?;
iter = AtomIterator::new_root(mss, total_len); // `AtomIterator::new_root` treats `len` as bytes available **from the
} else if end < file_len.saturating_sub(8) { // current reader position**, not the absolute file length. Passing
// Fast-start: skip a bounded mdat without linear read. // `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(); let mut mss = iter.into_inner();
mss.seek(SeekFrom::Start(end))?; 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 => { AtomType::Meta => {
+1
View File
@@ -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)', '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)', '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)', '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)',
], ],
}, },
{ {