mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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 => {
|
||||||
|
|||||||
@@ -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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user