mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(playback): stream buffering UI, M4A moov-at-end streaming, hot-cache spill (#737)
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix Defer seekbar/progress until HTTP stream is armed for both legacy and RangedHttpSource; show buffering overlay on cover art. Add MP4 tail prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so moov-at-end M4A can start without reading the full mdat. * feat(hot-cache): spill large ranged streams to disk for promote When a ranged HTTP download completes above the 64 MiB RAM promote cap, write the existing buffer once to app-data stream-spill/ and register it for hot-cache promote (rename) and replay via fetch_data. Analysis seeds from the spill file up to the local-file cap (512 MiB). * fix(ui): stream buffering — grayscale cover and static clock icon Desaturate player and queue cover art while isPlaybackBuffering; keep a non-animated clock overlay for visibility without the spinning animation. * fix(playback): review follow-up — tests, i18n, spill cleanup, changelog Clippy and test layout fixes; stream spill orphan cleanup on startup; buffering flag guard in progress handler; bufferingStream in all player locales; CHANGELOG and contributor credits for stream/M4A work. * docs: attribute stream buffering and M4A streaming to PR #737 * test(audio): avoid create_engine in stream spill unit test CI runners have no audio output device; test spill take/consume via the Mutex slot only, matching install_stream_completed_spill tests.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
|
||||
mod icy;
|
||||
mod local_file;
|
||||
mod mp4;
|
||||
mod radio;
|
||||
mod ranged_http;
|
||||
mod reader;
|
||||
@@ -32,7 +33,8 @@ pub(crate) const RADIO_BUF_CAPACITY: usize = 256 * 1024;
|
||||
pub(crate) const TRACK_STREAM_MIN_BUF_CAPACITY: usize = 1024 * 1024;
|
||||
/// Cap ring buffer growth when content-length is known.
|
||||
pub(crate) const TRACK_STREAM_MAX_BUF_CAPACITY: usize = 32 * 1024 * 1024;
|
||||
/// Max bytes kept in memory to promote a completed streamed track for fast replay/seek recovery.
|
||||
/// Max bytes kept in RAM (`stream_completed_cache`) for fast replay; larger completed
|
||||
/// ranged streams are spilled under app-data `stream-spill/` for hot-cache promote.
|
||||
pub(crate) const TRACK_STREAM_PROMOTE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
/// Hot/offline `psysonic-local://` files are read from disk for waveform/LUFS seeding — not the
|
||||
/// same heap pressure as retaining a full HTTP capture. FLAC/DSD tracks often exceed 64 MiB;
|
||||
@@ -42,7 +44,26 @@ pub(crate) const LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES: usize = 512 * 1024 * 1024;
|
||||
pub(crate) const TRACK_STREAM_MAX_RECONNECTS: u32 = 3;
|
||||
/// Seconds at stall threshold while paused before hard-disconnect.
|
||||
pub(crate) const RADIO_HARD_PAUSE_SECS: u64 = 5;
|
||||
/// AudioStreamReader timeout: if no audio bytes arrive for this long → EOF.
|
||||
/// Live radio: if no audio bytes arrive for this long → EOF.
|
||||
pub(crate) const RADIO_READ_TIMEOUT_SECS: u64 = 15;
|
||||
/// On-demand tracks (`track-stream`, `RangedHttpSource`): allow long gaps while a
|
||||
/// large file is still downloading (format probe may read/seek ahead of the filler).
|
||||
pub(crate) const TRACK_READ_TIMEOUT_SECS: u64 = 120;
|
||||
/// HTTP track paths (`AudioStreamReader`, `RangedHttpSource`): minimum linear
|
||||
/// download before audible playback and seekbar progress (demux probe may read
|
||||
/// far ahead of the play cursor).
|
||||
pub(crate) const TRACK_STREAM_PLAY_START_BYTES: u64 = 384 * 1024;
|
||||
|
||||
/// Arm deferred playback / progress once enough of the file is buffered.
|
||||
pub(crate) fn maybe_arm_stream_playback(downloaded: u64, playback_armed: &std::sync::atomic::AtomicBool) {
|
||||
use std::sync::atomic::Ordering;
|
||||
if !playback_armed.load(Ordering::Relaxed) && downloaded >= TRACK_STREAM_PLAY_START_BYTES {
|
||||
playback_armed.store(true, Ordering::SeqCst);
|
||||
crate::app_deprintln!(
|
||||
"[stream] playback armed after {} KiB buffered",
|
||||
downloaded / 1024
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Sleep interval when ring buffer is empty (prevents CPU spin).
|
||||
pub(crate) const RADIO_YIELD_MS: u64 = 2;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! MP4/M4A layout helpers for HTTP streaming path selection.
|
||||
|
||||
/// True when the Subsonic / sniffed container hint is ISO-BMFF (m4a, mp4, …).
|
||||
pub(crate) fn container_hint_is_mp4(hint: Option<&str>) -> bool {
|
||||
let Some(h) = hint else { return false };
|
||||
matches!(
|
||||
h.to_ascii_lowercase().as_str(),
|
||||
"m4a" | "m4af" | "mp4" | "m4b" | "mov" | "mp4a" | "isom"
|
||||
)
|
||||
}
|
||||
|
||||
/// Walk top-level atoms in `prefix` and return true when `mdat` appears before `moov`
|
||||
/// (classic non–fast-start layout — Symphonia must read the `moov` near EOF).
|
||||
pub(crate) fn mp4_moov_follows_mdat(prefix: &[u8]) -> bool {
|
||||
let mut pos = 0usize;
|
||||
let mut saw_mdat = false;
|
||||
while pos + 8 <= prefix.len() {
|
||||
let atom_size = match read_mp4_atom_size(prefix, pos) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
if atom_size < 8 {
|
||||
break;
|
||||
}
|
||||
let atom_type = &prefix[pos + 4..pos + 8];
|
||||
if atom_type == b"mdat" {
|
||||
saw_mdat = true;
|
||||
}
|
||||
if atom_type == b"moov" {
|
||||
return saw_mdat;
|
||||
}
|
||||
let advance = atom_size.min((prefix.len() - pos) as u64) as usize;
|
||||
if advance < 8 {
|
||||
break;
|
||||
}
|
||||
pos += advance;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True when we should prefetch the file tail before linear fill (moov-at-end).
|
||||
pub(crate) fn mp4_needs_tail_prefetch(prefix: &[u8], hint: Option<&str>) -> bool {
|
||||
if !container_hint_is_mp4(hint) {
|
||||
return false;
|
||||
}
|
||||
if prefix.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if mp4_moov_follows_mdat(prefix) {
|
||||
return true;
|
||||
}
|
||||
// mdat seen but no moov in the scanned prefix — moov is likely at EOF.
|
||||
let mut pos = 0usize;
|
||||
let mut saw_mdat = false;
|
||||
let mut saw_moov = false;
|
||||
while pos + 8 <= prefix.len() {
|
||||
let atom_size = match read_mp4_atom_size(prefix, pos) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
if atom_size < 8 {
|
||||
break;
|
||||
}
|
||||
let atom_type = &prefix[pos + 4..pos + 8];
|
||||
if atom_type == b"mdat" {
|
||||
saw_mdat = true;
|
||||
}
|
||||
if atom_type == b"moov" {
|
||||
saw_moov = true;
|
||||
break;
|
||||
}
|
||||
let advance = atom_size.min((prefix.len() - pos) as u64) as usize;
|
||||
if advance < 8 {
|
||||
break;
|
||||
}
|
||||
pos += advance;
|
||||
}
|
||||
saw_mdat && !saw_moov
|
||||
}
|
||||
|
||||
fn read_mp4_atom_size(data: &[u8], pos: usize) -> Option<u64> {
|
||||
if pos + 8 > data.len() {
|
||||
return None;
|
||||
}
|
||||
let size32 = u32::from_be_bytes(data[pos..pos + 4].try_into().ok()?) as u64;
|
||||
if size32 == 1 {
|
||||
if pos + 16 > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(u64::from_be_bytes(data[pos + 8..pos + 16].try_into().ok()?))
|
||||
} else {
|
||||
Some(size32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atom(typ: &[u8; 4], payload_len: usize) -> Vec<u8> {
|
||||
let size = (8 + payload_len) as u32;
|
||||
let mut v = Vec::with_capacity(8 + payload_len);
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(typ);
|
||||
v.resize(8 + payload_len, 0);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moov_after_mdat_detected() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"mdat", 100));
|
||||
buf.extend(atom(b"moov", 40));
|
||||
assert!(mp4_moov_follows_mdat(&buf));
|
||||
assert!(mp4_needs_tail_prefetch(&buf, Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moov_before_mdat_no_tail_prefetch() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"moov", 40));
|
||||
buf.extend(atom(b"mdat", 100));
|
||||
assert!(!mp4_moov_follows_mdat(&buf));
|
||||
assert!(!mp4_needs_tail_prefetch(&buf, Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prefix_with_m4a_hint_needs_tail_prefetch() {
|
||||
assert!(mp4_needs_tail_prefetch(&[], Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prefix_without_mp4_hint_skips_tail_prefetch() {
|
||||
assert!(!mp4_needs_tail_prefetch(&[], Some("mp3")));
|
||||
assert!(!mp4_needs_tail_prefetch(&[], None));
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,13 @@ use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{
|
||||
RADIO_READ_TIMEOUT_SECS, RADIO_YIELD_MS, TRACK_STREAM_MAX_RECONNECTS,
|
||||
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
||||
TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
};
|
||||
use crate::helpers::{
|
||||
install_stream_completed_spill, spawn_analysis_seed_from_spill_file, write_stream_spill_file,
|
||||
};
|
||||
use crate::state::StreamCompletedSpill;
|
||||
|
||||
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
|
||||
struct RangedLoudnessSeedHoldClear {
|
||||
@@ -49,6 +53,9 @@ pub(crate) struct RangedHttpSource {
|
||||
pub(crate) buf: Arc<Mutex<Vec<u8>>>,
|
||||
/// Bytes contiguously downloaded from offset 0.
|
||||
pub(crate) downloaded_to: Arc<AtomicUsize>,
|
||||
/// When set, bytes `[tail_filled_from..total_size)` are valid (moov-at-end prefetch).
|
||||
pub(crate) tail_ready: Arc<AtomicBool>,
|
||||
pub(crate) tail_filled_from: Arc<AtomicU64>,
|
||||
pub(crate) total_size: u64,
|
||||
pub(crate) pos: u64,
|
||||
/// Set when the download task terminates (success or hard error).
|
||||
@@ -57,6 +64,22 @@ pub(crate) struct RangedHttpSource {
|
||||
pub(crate) gen: u64,
|
||||
}
|
||||
|
||||
impl RangedHttpSource {
|
||||
fn region_ready(&self, start: u64, end: u64) -> bool {
|
||||
let dl = self.downloaded_to.load(Ordering::Relaxed) as u64;
|
||||
if end <= dl {
|
||||
return true;
|
||||
}
|
||||
if self.tail_ready.load(Ordering::Relaxed) {
|
||||
let tail_from = self.tail_filled_from.load(Ordering::Relaxed);
|
||||
if start >= tail_from && end <= self.total_size {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for RangedHttpSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
@@ -75,7 +98,9 @@ impl Read for RangedHttpSource {
|
||||
}
|
||||
let target_end = self.pos + max_read as u64;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
let stall_timeout = Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
let mut deadline = Instant::now() + stall_timeout;
|
||||
let mut last_dl_seen = self.downloaded_to.load(Ordering::Relaxed) as u64;
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
crate::app_deprintln!(
|
||||
@@ -85,13 +110,20 @@ impl Read for RangedHttpSource {
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
let dl = self.downloaded_to.load(Ordering::SeqCst) as u64;
|
||||
if dl >= target_end {
|
||||
if self.region_ready(self.pos, target_end) {
|
||||
break;
|
||||
}
|
||||
let dl = self.downloaded_to.load(Ordering::SeqCst) as u64;
|
||||
if dl > last_dl_seen {
|
||||
last_dl_seen = dl;
|
||||
deadline = Instant::now() + stall_timeout;
|
||||
}
|
||||
// Download finished but our cursor is past downloaded_to (e.g. seek
|
||||
// beyond a partial download that aborted). Return what we have.
|
||||
if self.done.load(Ordering::SeqCst) {
|
||||
if self.region_ready(self.pos, target_end) {
|
||||
break;
|
||||
}
|
||||
if dl > self.pos {
|
||||
let avail = (dl - self.pos) as usize;
|
||||
let src = self.buf.lock().unwrap();
|
||||
@@ -188,6 +220,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
mut on_partial: F,
|
||||
playback_armed: Option<&AtomicBool>,
|
||||
) -> (usize, RangedHttpLoopOutcome)
|
||||
where
|
||||
F: FnMut(usize, usize),
|
||||
@@ -274,6 +307,9 @@ where
|
||||
}
|
||||
downloaded += n;
|
||||
downloaded_to.store(downloaded, Ordering::SeqCst);
|
||||
if let Some(armed) = playback_armed {
|
||||
super::maybe_arm_stream_playback(downloaded as u64, armed);
|
||||
}
|
||||
on_partial(downloaded, total_size);
|
||||
let mb = downloaded / (1024 * 1024);
|
||||
while mb >= next_progress_mb {
|
||||
@@ -302,10 +338,111 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
|
||||
async fn ranged_write_http_range(
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
buf: &Arc<Mutex<Vec<u8>>>,
|
||||
start: u64,
|
||||
end_inclusive: u64,
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
) -> Result<usize, ()> {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return Err(());
|
||||
}
|
||||
let response = http_client
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return Err(());
|
||||
}
|
||||
if !(response.status() == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| response.status() == reqwest::StatusCode::OK)
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
let mut written = 0usize;
|
||||
let start_usize = start as usize;
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return Err(());
|
||||
}
|
||||
let chunk = chunk.map_err(|_| ())?;
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut b = buf.lock().unwrap();
|
||||
let end = (start_usize + written + chunk.len()).min(b.len());
|
||||
let n = end.saturating_sub(start_usize + written);
|
||||
b[start_usize + written..start_usize + written + n]
|
||||
.copy_from_slice(&chunk[..n]);
|
||||
written += n;
|
||||
if start_usize + written > end_inclusive as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Prefetch the tail of a moov-at-end MP4 so Symphonia can parse metadata while
|
||||
/// the linear download still fills `mdat` from offset 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn ranged_prefetch_mp4_tail(
|
||||
http_client: reqwest::Client,
|
||||
url: String,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
total_size: usize,
|
||||
tail_ready: Arc<AtomicBool>,
|
||||
tail_filled_from: Arc<AtomicU64>,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
) {
|
||||
const MIN_TAIL: u64 = 256 * 1024;
|
||||
const MAX_TAIL: u64 = 8 * 1024 * 1024;
|
||||
let total = total_size as u64;
|
||||
if total < MIN_TAIL + 64 * 1024 {
|
||||
return;
|
||||
}
|
||||
let tail_len = MAX_TAIL.min(total / 2).max(MIN_TAIL);
|
||||
let tail_from = total.saturating_sub(tail_len);
|
||||
let end_inclusive = total.saturating_sub(1);
|
||||
match ranged_write_http_range(
|
||||
&http_client,
|
||||
&url,
|
||||
&buf,
|
||||
tail_from,
|
||||
end_inclusive,
|
||||
gen,
|
||||
&gen_arc,
|
||||
)
|
||||
.await
|
||||
{
|
||||
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);
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: moov-at-end tail prefetch {} KiB (from byte {})",
|
||||
written / 1024,
|
||||
tail_from / 1024
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
crate::app_deprintln!("[stream] ranged: moov-at-end tail prefetch failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear downloader for `RangedHttpSource`: fills the pre-allocated buffer
|
||||
/// from offset 0 to total_size. Reconnects via HTTP Range from the current
|
||||
/// `downloaded` offset on transient errors. On completion (full track) the
|
||||
/// data is promoted to `stream_completed_cache` for fast replay.
|
||||
/// data is promoted to `stream_completed_cache` (≤ 64 MiB) or spilled to disk for hot cache.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn ranged_download_task(
|
||||
gen: u64,
|
||||
@@ -319,6 +456,7 @@ pub(crate) async fn ranged_download_task(
|
||||
downloaded_to: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
spill_cache_slot: Arc<Mutex<Option<StreamCompletedSpill>>>,
|
||||
normalization_engine: Arc<AtomicU32>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
@@ -326,6 +464,10 @@ pub(crate) async fn ranged_download_task(
|
||||
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
|
||||
// track; `None` for large files where ranged skips seed (needs backfill).
|
||||
loudness_seed_hold: Option<LoudnessSeedHold>,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
format_hint: Option<String>,
|
||||
tail_ready: Arc<AtomicBool>,
|
||||
tail_filled_from: Arc<AtomicU64>,
|
||||
) {
|
||||
let _ranged_loudness_hold_clear = match (loudness_seed_hold.as_ref(), cache_track_id.as_ref()) {
|
||||
(Some(slot), Some(tid)) => {
|
||||
@@ -393,6 +535,33 @@ pub(crate) async fn ranged_download_task(
|
||||
);
|
||||
};
|
||||
|
||||
let tail_prefetch = super::mp4::mp4_needs_tail_prefetch(&[], format_hint.as_deref());
|
||||
let tail_handle = if tail_prefetch {
|
||||
let client = http_client.clone();
|
||||
let url_tail = url.clone();
|
||||
let buf_tail = buf.clone();
|
||||
let tail_ready_bg = tail_ready.clone();
|
||||
let tail_from_bg = tail_filled_from.clone();
|
||||
let armed_bg = playback_armed.clone();
|
||||
let gen_bg = gen_arc.clone();
|
||||
Some(tokio::spawn(async move {
|
||||
ranged_prefetch_mp4_tail(
|
||||
client,
|
||||
url_tail,
|
||||
buf_tail,
|
||||
total_size,
|
||||
tail_ready_bg,
|
||||
tail_from_bg,
|
||||
armed_bg,
|
||||
gen,
|
||||
gen_bg,
|
||||
)
|
||||
.await;
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (downloaded, outcome) = ranged_http_download_loop(
|
||||
http_client,
|
||||
&url,
|
||||
@@ -402,9 +571,15 @@ pub(crate) async fn ranged_download_task(
|
||||
gen,
|
||||
&gen_arc,
|
||||
on_partial,
|
||||
Some(&playback_armed),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(handle) = tail_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
playback_armed.store(true, Ordering::SeqCst);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
|
||||
if matches!(outcome, RangedHttpLoopOutcome::Superseded) {
|
||||
@@ -428,34 +603,76 @@ pub(crate) async fn ranged_download_task(
|
||||
);
|
||||
}
|
||||
|
||||
if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
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)",
|
||||
tid,
|
||||
total_size as f64 / (1024.0 * 1024.0),
|
||||
total_size
|
||||
);
|
||||
}
|
||||
let t_clone = Instant::now();
|
||||
let data = buf.lock().unwrap().clone();
|
||||
if total_size > 32 * 1024 * 1024 {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: buffer cloned in_ms={}",
|
||||
t_clone.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_track_id {
|
||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
|
||||
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
||||
if downloaded == total_size && total_size > 0 {
|
||||
if total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
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)",
|
||||
tid,
|
||||
total_size as f64 / (1024.0 * 1024.0),
|
||||
total_size
|
||||
);
|
||||
}
|
||||
let t_clone = Instant::now();
|
||||
let data = buf.lock().unwrap().clone();
|
||||
if total_size > 32 * 1024 * 1024 {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: buffer cloned in_ms={}",
|
||||
t_clone.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_track_id {
|
||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
|
||||
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
||||
}
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
|
||||
} else if let Some(track_id) = cache_track_id.clone() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
let spill_result = {
|
||||
let spill_bytes = buf.lock().unwrap();
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
write_stream_spill_file(&app, &track_id, &spill_bytes)
|
||||
};
|
||||
match spill_result {
|
||||
Ok(path) => {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: spilled to disk track_id={} size_mib={:.2} path={}",
|
||||
track_id,
|
||||
total_size as f64 / (1024.0 * 1024.0),
|
||||
path.display()
|
||||
);
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return;
|
||||
}
|
||||
install_stream_completed_spill(&spill_cache_slot, url, path.clone());
|
||||
spawn_analysis_seed_from_spill_file(
|
||||
&app,
|
||||
&track_id,
|
||||
path,
|
||||
gen,
|
||||
&gen_arc,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[stream] ranged: spill write failed track_id={}: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +691,8 @@ mod tests {
|
||||
RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready: Arc::new(AtomicBool::new(true)),
|
||||
tail_filled_from: Arc::new(AtomicU64::new(0)),
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
done,
|
||||
@@ -541,6 +760,8 @@ mod tests {
|
||||
let mut src = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready: Arc::new(AtomicBool::new(false)),
|
||||
tail_filled_from: Arc::new(AtomicU64::new(0)),
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
done,
|
||||
@@ -554,6 +775,35 @@ mod tests {
|
||||
assert_eq!(src.pos, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_blocks_until_download_progress_reaches_seek_target() {
|
||||
let total: u64 = 8;
|
||||
let buf = Arc::new(Mutex::new(vec![1, 2, 3, 4, 5, 6, 7, 8]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(2));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let gen_arc = Arc::new(AtomicU64::new(1));
|
||||
let dl_bg = downloaded_to.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
dl_bg.store(8, Ordering::SeqCst);
|
||||
});
|
||||
let mut src = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready: Arc::new(AtomicBool::new(false)),
|
||||
tail_filled_from: Arc::new(AtomicU64::new(0)),
|
||||
total_size: total,
|
||||
pos: 6,
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
};
|
||||
let mut out = [0u8; 2];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(out, [7, 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_returns_zero_when_done_with_no_data_ahead_of_cursor() {
|
||||
let total: u64 = 8;
|
||||
@@ -564,6 +814,8 @@ mod tests {
|
||||
let mut src = RangedHttpSource {
|
||||
buf: src_buf,
|
||||
downloaded_to,
|
||||
tail_ready: Arc::new(AtomicBool::new(false)),
|
||||
tail_filled_from: Arc::new(AtomicU64::new(0)),
|
||||
total_size: total,
|
||||
pos: 5, // past downloaded_to
|
||||
done,
|
||||
@@ -676,6 +928,7 @@ mod tests {
|
||||
1,
|
||||
&gen_arc,
|
||||
|_, _| {},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -710,6 +963,7 @@ mod tests {
|
||||
1,
|
||||
&gen_arc,
|
||||
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -736,7 +990,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(1024);
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {})
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
|
||||
@@ -767,7 +1021,7 @@ mod tests {
|
||||
gen_arc.store(99, Ordering::SeqCst);
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {})
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
|
||||
@@ -826,7 +1080,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(body.len());
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {})
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
// Stream finishes via a Range-resumed second request.
|
||||
@@ -868,7 +1122,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(body.len());
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {})
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
|
||||
|
||||
@@ -18,9 +18,10 @@ use ringbuf::HeapCons;
|
||||
use ringbuf::traits::{Consumer, Observer};
|
||||
use symphonia::core::io::MediaSource;
|
||||
|
||||
use super::{RADIO_READ_TIMEOUT_SECS, RADIO_YIELD_MS};
|
||||
use super::{RADIO_YIELD_MS};
|
||||
|
||||
pub(crate) struct AudioStreamReader {
|
||||
pub(crate) read_timeout_secs: u64,
|
||||
pub(crate) cons: Mutex<HeapCons<u8>>,
|
||||
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
|
||||
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
|
||||
@@ -53,7 +54,7 @@ impl Read for AudioStreamReader {
|
||||
if let Some(c) = newest {
|
||||
*self.cons.lock().unwrap() = c;
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
|
||||
}
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
@@ -66,7 +67,7 @@ impl Read for AudioStreamReader {
|
||||
self.pos += read as u64;
|
||||
// Reset deadline: data arrived, so connection is alive.
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
|
||||
return Ok(read);
|
||||
}
|
||||
if self
|
||||
@@ -80,7 +81,7 @@ impl Read for AudioStreamReader {
|
||||
crate::app_eprintln!(
|
||||
"[{}] AudioStreamReader: {}s without data → EOF",
|
||||
self.source_tag,
|
||||
RADIO_READ_TIMEOUT_SECS
|
||||
self.read_timeout_secs
|
||||
);
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
|
||||
@@ -16,7 +16,9 @@ use ringbuf::traits::Producer;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES};
|
||||
use super::{
|
||||
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn track_download_task(
|
||||
@@ -33,6 +35,7 @@ pub(crate) async fn track_download_task(
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
cache_track_id: Option<String>,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
@@ -147,6 +150,7 @@ pub(crate) async fn track_download_task(
|
||||
}
|
||||
offset += pushed;
|
||||
downloaded += pushed as u64;
|
||||
maybe_arm_stream_playback(downloaded, &playback_armed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,6 +181,7 @@ pub(crate) async fn track_download_task(
|
||||
data: capture,
|
||||
});
|
||||
}
|
||||
playback_armed.store(true, Ordering::SeqCst);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user