mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-28 01:56:47 +00:00
1475 lines
54 KiB
Rust
1475 lines
54 KiB
Rust
//! `RangedHttpSource` — seekable HTTP-backed `MediaSource`, plus its
|
|
//! background `ranged_download_task` linear filler.
|
|
//!
|
|
//! Pre-allocates a `Vec<u8>` of total track size. The download task fills it
|
|
//! linearly from offset 0 via streaming HTTP. `Read` blocks (with timeout)
|
|
//! until requested bytes are downloaded; `Seek` only updates the cursor.
|
|
//!
|
|
//! Reports `is_seekable=true` so Symphonia performs time-based seeks via the
|
|
//! format reader. Backward seeks: instant (data in buffer). Forward seeks
|
|
//! beyond `downloaded_to`: `Read` blocks until the linear download catches up.
|
|
//!
|
|
//! Requires the server to respond with both `Content-Length` and
|
|
//! `Accept-Ranges: bytes` so reconnects can resume via HTTP `Range`.
|
|
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use futures_util::StreamExt;
|
|
use symphonia::core::io::MediaSource;
|
|
use tauri::{AppHandle, Emitter};
|
|
|
|
use super::super::engine::PlaybackHttpHeaders;
|
|
use super::super::state::PreloadedTrack;
|
|
use super::{
|
|
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
|
TRACK_STREAM_PROMOTE_MAX_BYTES,
|
|
};
|
|
use crate::analysis_dispatch::{
|
|
dispatch_track_analysis_bytes, analysis_priority_for_app, resolve_server_id_for_app,
|
|
spawn_track_analysis_file, TrackAnalysisOrigin,
|
|
};
|
|
use crate::helpers::{install_stream_completed_spill, write_stream_spill_file};
|
|
use crate::state::StreamCompletedSpill;
|
|
|
|
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
|
|
struct RangedLoudnessSeedHoldClear {
|
|
slot: Arc<Mutex<Option<(String, u64)>>>,
|
|
tid: String,
|
|
gen: u64,
|
|
}
|
|
|
|
impl Drop for RangedLoudnessSeedHoldClear {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut g) = self.slot.lock() {
|
|
if matches!(&*g, Some((t, gen)) if t == &self.tid && *gen == self.gen) {
|
|
*g = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Minimum bytes fetched per on-demand Range request. A seek often triggers a
|
|
/// short read; fetching a window amortizes the HTTP round-trip and lets the few
|
|
/// pages a bisection lands on (and the playback that follows a forward seek) be
|
|
/// served without a fresh request each time.
|
|
const OD_FETCH_WINDOW: u64 = 1024 * 1024;
|
|
/// Forward gap (cursor ahead of the contiguous linear download) above which a
|
|
/// read is treated as a *seek* and served by an on-demand HTTP Range fetch
|
|
/// instead of waiting for the linear filler to catch up. Below it we assume
|
|
/// ordinary read-ahead that the linear download will satisfy shortly, so we do
|
|
/// not issue redundant range requests during normal (slightly starved) play.
|
|
const OD_SEEK_GAP: u64 = 512 * 1024;
|
|
|
|
/// Random-access companion for [`RangedHttpSource`]: fetches arbitrary byte
|
|
/// ranges over HTTP `Range` on demand so seeks (which jump the read cursor far
|
|
/// ahead of the linear download) resolve quickly instead of blocking until the
|
|
/// linear filler reaches the target.
|
|
///
|
|
/// symphonia 0.6's Ogg demuxer seeks by *bisection* — it reads pages at
|
|
/// midpoints across the whole byte range, and its probe scans the last pages to
|
|
/// find the stream-end timestamp. On a purely linear-fill source every such read
|
|
/// would block until the download caught up (effectively forcing a full
|
|
/// download before any seek). On-demand range fetches make those reads cheap.
|
|
pub(crate) struct OnDemand {
|
|
http: reqwest::Client,
|
|
handle: tokio::runtime::Handle,
|
|
url: String,
|
|
buf: Arc<Mutex<Vec<u8>>>,
|
|
total_size: u64,
|
|
gen_arc: Arc<AtomicU64>,
|
|
gen: u64,
|
|
/// Byte ranges already fetched on demand (sorted/merged not required — N is
|
|
/// the handful of seek targets per track).
|
|
filled: Mutex<Vec<(u64, u64)>>,
|
|
/// Ranges with an in-flight fetch, so a polling read does not respawn them.
|
|
inflight: Mutex<Vec<(u64, u64)>>,
|
|
/// Bumped after every completed (success or failure) fetch so the read loop
|
|
/// can reset its stall deadline while on-demand fetches make progress.
|
|
progress: AtomicU64,
|
|
http_headers: PlaybackHttpHeaders,
|
|
}
|
|
|
|
impl OnDemand {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn new(
|
|
http: reqwest::Client,
|
|
handle: tokio::runtime::Handle,
|
|
url: String,
|
|
buf: Arc<Mutex<Vec<u8>>>,
|
|
total_size: u64,
|
|
gen_arc: Arc<AtomicU64>,
|
|
gen: u64,
|
|
http_headers: PlaybackHttpHeaders,
|
|
) -> Self {
|
|
OnDemand {
|
|
http,
|
|
handle,
|
|
url,
|
|
buf,
|
|
total_size,
|
|
gen_arc,
|
|
gen,
|
|
filled: Mutex::new(Vec::new()),
|
|
inflight: Mutex::new(Vec::new()),
|
|
progress: AtomicU64::new(0),
|
|
http_headers,
|
|
}
|
|
}
|
|
|
|
fn covers(&self, start: u64, end: u64) -> bool {
|
|
self.filled
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|&(s, e)| s <= start && end <= e)
|
|
}
|
|
|
|
fn inflight_covers(&self, start: u64, end: u64) -> bool {
|
|
self.inflight
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|&(s, e)| s <= start && end <= e)
|
|
}
|
|
|
|
/// Spawn a Range fetch covering at least `[start, end)` (rounded up to
|
|
/// [`OD_FETCH_WINDOW`]) unless it is already filled or in flight. Returns
|
|
/// immediately; the caller polls [`OnDemand::covers`] / `progress`.
|
|
fn request(self: &Arc<Self>, start: u64, end: u64) {
|
|
if start >= self.total_size {
|
|
return;
|
|
}
|
|
let want_end = end.max(start + OD_FETCH_WINDOW).min(self.total_size);
|
|
if self.covers(start, want_end) || self.inflight_covers(start, want_end) {
|
|
return;
|
|
}
|
|
self.inflight.lock().unwrap().push((start, want_end));
|
|
let me = Arc::clone(self);
|
|
self.handle.spawn(async move {
|
|
let end_inclusive = want_end.saturating_sub(1);
|
|
let res = ranged_write_http_range(
|
|
&me.http,
|
|
&me.url,
|
|
&me.buf,
|
|
start,
|
|
end_inclusive,
|
|
me.gen,
|
|
&me.gen_arc,
|
|
&me.http_headers,
|
|
)
|
|
.await;
|
|
if let Ok(written) = res {
|
|
if written > 0 {
|
|
me.filled.lock().unwrap().push((start, start + written as u64));
|
|
}
|
|
}
|
|
// Drop the reservation either way so a failed fetch can be retried.
|
|
me.inflight
|
|
.lock()
|
|
.unwrap()
|
|
.retain(|&(s, e)| !(s == start && e == want_end));
|
|
me.progress.fetch_add(1, Ordering::SeqCst);
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) struct RangedHttpSource {
|
|
/// Pre-allocated buffer of total size. Filled linearly from offset 0.
|
|
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).
|
|
pub(crate) done: Arc<AtomicBool>,
|
|
pub(crate) gen_arc: Arc<AtomicU64>,
|
|
pub(crate) gen: u64,
|
|
/// On-demand random-access fetcher. `None` keeps the legacy linear-only
|
|
/// behaviour (used by unit tests); production ranged playback sets it so
|
|
/// seeks resolve via HTTP `Range` instead of blocking on the linear filler.
|
|
pub(crate) on_demand: Option<Arc<OnDemand>>,
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
if let Some(od) = &self.on_demand {
|
|
if od.covers(start, end) {
|
|
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 {
|
|
crate::app_deprintln!(
|
|
"[stream] ranged-stream read EOF: superseded before first read (gen={} cur={} pos={}/{})",
|
|
self.gen, self.gen_arc.load(Ordering::SeqCst), self.pos, self.total_size
|
|
);
|
|
return Ok(0);
|
|
}
|
|
if self.pos >= self.total_size {
|
|
return Ok(0);
|
|
}
|
|
let max_read = ((self.total_size - self.pos) as usize).min(buf.len());
|
|
if max_read == 0 {
|
|
return Ok(0);
|
|
}
|
|
let target_end = self.pos + max_read as u64;
|
|
|
|
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;
|
|
let mut last_od_seen = self
|
|
.on_demand
|
|
.as_ref()
|
|
.map(|od| od.progress.load(Ordering::Relaxed))
|
|
.unwrap_or(0);
|
|
loop {
|
|
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
|
crate::app_deprintln!(
|
|
"[stream] ranged-stream read EOF: superseded mid-wait (gen={} cur={} pos={}/{} dl={})",
|
|
self.gen, self.gen_arc.load(Ordering::SeqCst), self.pos, self.total_size,
|
|
self.downloaded_to.load(Ordering::SeqCst)
|
|
);
|
|
return Ok(0);
|
|
}
|
|
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;
|
|
}
|
|
// A read whose cursor is far ahead of the contiguous linear download
|
|
// is a seek (Ogg bisection midpoint, end-of-stream probe, or a
|
|
// forward scrub). Serve it from an on-demand HTTP Range fetch rather
|
|
// than blocking until the linear filler crawls there. While the
|
|
// download is still running; an aborted download keeps the legacy
|
|
// partial/EOF behaviour below.
|
|
if let Some(od) = &self.on_demand {
|
|
let od_progress = od.progress.load(Ordering::SeqCst);
|
|
if od_progress != last_od_seen {
|
|
last_od_seen = od_progress;
|
|
deadline = Instant::now() + stall_timeout;
|
|
}
|
|
if !self.done.load(Ordering::SeqCst)
|
|
&& self.pos > dl.saturating_add(OD_SEEK_GAP)
|
|
{
|
|
od.request(self.pos, target_end);
|
|
}
|
|
}
|
|
// 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();
|
|
let start = self.pos as usize;
|
|
buf[..avail].copy_from_slice(&src[start..start + avail]);
|
|
drop(src);
|
|
self.pos += avail as u64;
|
|
return Ok(avail);
|
|
}
|
|
crate::app_deprintln!(
|
|
"[stream] ranged-stream read EOF: download done with no data ahead of cursor (pos={}/{} dl={})",
|
|
self.pos, self.total_size, dl
|
|
);
|
|
return Ok(0);
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::TimedOut,
|
|
"ranged-http: no data within timeout",
|
|
));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
|
}
|
|
|
|
let src = self.buf.lock().unwrap();
|
|
let start = self.pos as usize;
|
|
let end = start + max_read;
|
|
buf[..max_read].copy_from_slice(&src[start..end]);
|
|
drop(src);
|
|
self.pos += max_read as u64;
|
|
Ok(max_read)
|
|
}
|
|
}
|
|
|
|
impl Seek for RangedHttpSource {
|
|
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
|
let new_pos: i64 = match pos {
|
|
SeekFrom::Start(p) => p as i64,
|
|
SeekFrom::Current(p) => self.pos as i64 + p,
|
|
SeekFrom::End(p) => self.total_size as i64 + p,
|
|
};
|
|
if new_pos < 0 {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"ranged-http: seek before start",
|
|
));
|
|
}
|
|
self.pos = (new_pos as u64).min(self.total_size);
|
|
Ok(self.pos)
|
|
}
|
|
}
|
|
|
|
impl MediaSource for RangedHttpSource {
|
|
fn is_seekable(&self) -> bool { true }
|
|
fn byte_len(&self) -> Option<u64> { Some(self.total_size) }
|
|
}
|
|
|
|
/// Slot used to coordinate "ranged playback seeds on completion → defer HTTP
|
|
/// backfill for that track" between [`ranged_download_task`] and the analysis
|
|
/// runtime; the inner `(track_id, deadline_unix_ms)` describes the active hold.
|
|
pub(crate) type LoudnessSeedHold = Arc<Mutex<Option<(String, u64)>>>;
|
|
|
|
/// Outcome of [`ranged_http_download_loop`] — total bytes written to the buffer
|
|
/// plus the reason the loop stopped. The wrapper task uses this to decide
|
|
/// whether to promote the buffer to the stream-complete cache.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum RangedHttpLoopOutcome {
|
|
/// Stream ended with `downloaded == total_size`.
|
|
Completed,
|
|
/// `gen_arc` no longer matches `gen` — playback skipped to another track.
|
|
Superseded,
|
|
/// Stream stopped early without finishing — server cut, reconnect budget
|
|
/// exhausted, or non-success status on the (re)connect response.
|
|
Aborted,
|
|
}
|
|
|
|
/// Returns `(downloaded_bytes, outcome)`. The caller is responsible for setting
|
|
/// any `done` flag, promoting the buffer to a cache, or kicking off analysis
|
|
/// seeding once the loop returns.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn ranged_http_download_loop<F>(
|
|
http_client: reqwest::Client,
|
|
url: &str,
|
|
initial_response: reqwest::Response,
|
|
buf: &Arc<Mutex<Vec<u8>>>,
|
|
downloaded_to: &Arc<AtomicUsize>,
|
|
gen: u64,
|
|
gen_arc: &Arc<AtomicU64>,
|
|
http_headers: &PlaybackHttpHeaders,
|
|
mut on_partial: F,
|
|
playback_armed: Option<&AtomicBool>,
|
|
) -> (usize, RangedHttpLoopOutcome)
|
|
where
|
|
F: FnMut(usize, usize),
|
|
{
|
|
let total_size = buf.lock().unwrap().len();
|
|
let mut downloaded: usize = 0;
|
|
let mut reconnects: u32 = 0;
|
|
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
|
let mut next_progress_mb: usize = 0;
|
|
|
|
'outer: loop {
|
|
let response = if let Some(r) = next_response.take() {
|
|
r
|
|
} else {
|
|
let mut req = http_client.get(url);
|
|
if downloaded > 0 {
|
|
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
|
}
|
|
req = http_headers.apply(url, req);
|
|
match req.send().await {
|
|
Ok(r) => r,
|
|
Err(err) => {
|
|
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
|
crate::app_eprintln!(
|
|
"[audio] ranged reconnect failed after {} attempts: {}",
|
|
reconnects, err
|
|
);
|
|
return (downloaded, RangedHttpLoopOutcome::Aborted);
|
|
}
|
|
reconnects += 1;
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
continue 'outer;
|
|
}
|
|
}
|
|
};
|
|
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
|
crate::app_eprintln!(
|
|
"[audio] ranged reconnect returned {}, expected 206",
|
|
response.status()
|
|
);
|
|
return (downloaded, RangedHttpLoopOutcome::Aborted);
|
|
}
|
|
if downloaded == 0 && !response.status().is_success() {
|
|
crate::app_eprintln!("[audio] ranged HTTP {}", response.status());
|
|
return (downloaded, RangedHttpLoopOutcome::Aborted);
|
|
}
|
|
|
|
let mut byte_stream = response.bytes_stream();
|
|
while let Some(chunk) = byte_stream.next().await {
|
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
|
crate::app_deprintln!(
|
|
"[stream] ranged dl superseded by skip: gen={}→{} downloaded={}/{} bytes",
|
|
gen, gen_arc.load(Ordering::SeqCst), downloaded, total_size
|
|
);
|
|
return (downloaded, RangedHttpLoopOutcome::Superseded);
|
|
}
|
|
let chunk = match chunk {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
|
crate::app_eprintln!(
|
|
"[audio] ranged dl error after {} reconnects: {}",
|
|
reconnects, e
|
|
);
|
|
return (downloaded, RangedHttpLoopOutcome::Aborted);
|
|
}
|
|
reconnects += 1;
|
|
crate::app_eprintln!(
|
|
"[audio] ranged dl error (attempt {}/{}): {} — reconnecting",
|
|
reconnects, TRACK_STREAM_MAX_RECONNECTS, e
|
|
);
|
|
next_response = None;
|
|
continue 'outer;
|
|
}
|
|
};
|
|
reconnects = 0;
|
|
let writable = total_size.saturating_sub(downloaded);
|
|
if writable == 0 {
|
|
break;
|
|
}
|
|
let n = chunk.len().min(writable);
|
|
{
|
|
let mut b = buf.lock().unwrap();
|
|
b[downloaded..downloaded + n].copy_from_slice(&chunk[..n]);
|
|
}
|
|
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 {
|
|
let pct = if total_size > 0 {
|
|
(downloaded as f64 / total_size as f64 * 100.0) as u32
|
|
} else {
|
|
0u32
|
|
};
|
|
crate::app_deprintln!(
|
|
"[stream] dl progress: {} MB / {} MB ({}%)",
|
|
mb,
|
|
total_size / (1024 * 1024),
|
|
pct
|
|
);
|
|
next_progress_mb = mb + 1;
|
|
}
|
|
if downloaded >= total_size {
|
|
break;
|
|
}
|
|
}
|
|
// Stream ended cleanly (or we wrote total_size).
|
|
if downloaded >= total_size {
|
|
return (downloaded, RangedHttpLoopOutcome::Completed);
|
|
}
|
|
return (downloaded, RangedHttpLoopOutcome::Aborted);
|
|
}
|
|
}
|
|
|
|
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
|
|
#[allow(clippy::too_many_arguments)]
|
|
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>,
|
|
http_headers: &PlaybackHttpHeaders,
|
|
) -> Result<usize, ()> {
|
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
|
return Err(());
|
|
}
|
|
let response = http_headers
|
|
.apply(
|
|
url,
|
|
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(());
|
|
}
|
|
// Require 206 for any non-zero offset. A server that ignored the `Range`
|
|
// header and replied 200 returns the *whole* body from byte 0; writing that
|
|
// at `start` would corrupt the buffer. A 200 is only safe when we asked from
|
|
// offset 0 (the body genuinely starts there).
|
|
let status = response.status();
|
|
let ok = status == reqwest::StatusCode::PARTIAL_CONTENT
|
|
|| (status == reqwest::StatusCode::OK && start == 0);
|
|
if !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>,
|
|
http_headers: PlaybackHttpHeaders,
|
|
) {
|
|
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,
|
|
&http_headers,
|
|
)
|
|
.await
|
|
{
|
|
Ok(written) if written > 0 => {
|
|
tail_filled_from.store(tail_from, Ordering::Relaxed);
|
|
tail_ready.store(true, Ordering::SeqCst);
|
|
if !playback_armed.load(Ordering::Relaxed) {
|
|
playback_armed.store(true, Ordering::SeqCst);
|
|
crate::app_deprintln!(
|
|
"[stream] playback armed after moov tail prefetch ({} KiB)",
|
|
written / 1024
|
|
);
|
|
}
|
|
crate::app_deprintln!(
|
|
"[stream] ranged: moov-at-end tail prefetch {} KiB (from byte {})",
|
|
written / 1024,
|
|
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` (≤ 64 MiB) or spilled to disk for hot cache.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn ranged_download_task(
|
|
gen: u64,
|
|
gen_arc: Arc<AtomicU64>,
|
|
http_client: reqwest::Client,
|
|
app: AppHandle,
|
|
_duration_hint: f64,
|
|
url: String,
|
|
initial_response: reqwest::Response,
|
|
buf: Arc<Mutex<Vec<u8>>>,
|
|
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>,
|
|
cache_track_id: Option<String>,
|
|
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
|
server_id: Option<String>,
|
|
http_headers: PlaybackHttpHeaders,
|
|
// 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)) => {
|
|
let t = tid.clone();
|
|
{
|
|
let mut g = slot.lock().unwrap();
|
|
*g = Some((t.clone(), gen));
|
|
}
|
|
Some(RangedLoudnessSeedHoldClear {
|
|
slot: Arc::clone(slot),
|
|
tid: t,
|
|
gen,
|
|
})
|
|
}
|
|
_ => None,
|
|
};
|
|
let total_size = buf.lock().unwrap().len();
|
|
let dl_started = Instant::now();
|
|
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
|
let url_for_emit = url.clone();
|
|
let app_for_emit = app.clone();
|
|
|
|
crate::app_deprintln!(
|
|
"[stream] ranged dl start: total={} KiB (~{:.2} MiB)",
|
|
total_size.saturating_div(1024),
|
|
total_size as f64 / (1024.0 * 1024.0)
|
|
);
|
|
|
|
let on_partial = |downloaded: usize, total: usize| {
|
|
if downloaded < crate::helpers::PARTIAL_LOUDNESS_MIN_BYTES
|
|
|| total == 0
|
|
|| last_partial_loudness_emit.elapsed()
|
|
< Duration::from_millis(crate::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
|
|
{
|
|
return;
|
|
}
|
|
last_partial_loudness_emit = Instant::now();
|
|
if normalization_engine.load(Ordering::Relaxed) != 2 {
|
|
return;
|
|
}
|
|
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
|
let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed))
|
|
.clamp(-24.0, 0.0);
|
|
let Some(provisional_db) = crate::helpers::provisional_loudness_gain_from_progress(
|
|
downloaded,
|
|
total,
|
|
target_lufs,
|
|
start_db,
|
|
) else {
|
|
return;
|
|
};
|
|
let track_key = crate::helpers::playback_identity(&url_for_emit)
|
|
.unwrap_or_else(|| url_for_emit.clone());
|
|
if !crate::ipc::partial_loudness_should_emit(&track_key, provisional_db) {
|
|
return;
|
|
}
|
|
let _ = app_for_emit.emit(
|
|
"analysis:loudness-partial",
|
|
crate::ipc::PartialLoudnessPayload {
|
|
track_id: crate::helpers::playback_identity(&url_for_emit),
|
|
gain_db: provisional_db,
|
|
target_lufs,
|
|
is_partial: true,
|
|
},
|
|
);
|
|
};
|
|
|
|
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();
|
|
let headers_bg = http_headers.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,
|
|
headers_bg,
|
|
)
|
|
.await;
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let linear_arm = if tail_prefetch {
|
|
None
|
|
} else {
|
|
Some(playback_armed.as_ref())
|
|
};
|
|
let (downloaded, outcome) = ranged_http_download_loop(
|
|
http_client,
|
|
&url,
|
|
initial_response,
|
|
&buf,
|
|
&downloaded_to,
|
|
gen,
|
|
&gen_arc,
|
|
&http_headers,
|
|
on_partial,
|
|
linear_arm,
|
|
)
|
|
.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) {
|
|
return;
|
|
}
|
|
|
|
if downloaded < total_size {
|
|
crate::app_eprintln!(
|
|
"[stream] ranged dl ABORTED: {} / {} bytes in {:.2}s (track_id={:?})",
|
|
downloaded,
|
|
total_size,
|
|
dl_started.elapsed().as_secs_f64(),
|
|
cache_track_id
|
|
);
|
|
} else {
|
|
crate::app_deprintln!(
|
|
"[stream] dl done: {} / {} bytes in {:.2}s",
|
|
downloaded,
|
|
total_size,
|
|
dl_started.elapsed().as_secs_f64()
|
|
);
|
|
}
|
|
|
|
if downloaded == total_size && total_size > 0 {
|
|
if total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
|
if super::container_hint_is_mp4(format_hint.as_deref()) {
|
|
let guard = buf.lock().unwrap();
|
|
if !super::isobmff_buffer_looks_complete(&guard) {
|
|
super::log_isobmff_buffer_diagnostic(
|
|
&guard,
|
|
format_hint.as_deref(),
|
|
"ranged-dl-complete-incomplete",
|
|
);
|
|
} else if super::mp4_suspect_zero_holes(&guard) {
|
|
super::log_isobmff_buffer_diagnostic(
|
|
&guard,
|
|
format_hint.as_deref(),
|
|
"ranged-dl-complete-zero-holes",
|
|
);
|
|
}
|
|
}
|
|
if let Some(ref tid) = cache_track_id {
|
|
crate::app_deprintln!(
|
|
"[stream] ranged: HTTP buffer full track_id={} size_mib={:.2} — cloning {} bytes then full-track analysis (cpu-seed queue; this task awaits completion)",
|
|
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 sid = resolve_server_id_for_app(&app, server_id.as_deref());
|
|
let priority = analysis_priority_for_app(&app, &sid, &track_id, None);
|
|
if let Err(e) = dispatch_track_analysis_bytes(
|
|
&app,
|
|
TrackAnalysisOrigin::StreamDownloadComplete,
|
|
&sid,
|
|
&track_id,
|
|
data.clone(),
|
|
priority,
|
|
)
|
|
.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());
|
|
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
|
|
let priority = analysis_priority_for_app(&app, &sid, &track_id, None);
|
|
spawn_track_analysis_file(
|
|
app.clone(),
|
|
TrackAnalysisOrigin::StreamSpillFile,
|
|
sid,
|
|
track_id,
|
|
path,
|
|
priority,
|
|
Some((gen, gen_arc.clone())),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
crate::app_eprintln!(
|
|
"[stream] ranged: spill write failed track_id={}: {}",
|
|
track_id,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Build a `RangedHttpSource` with `total_size` bytes, all already
|
|
/// "downloaded" — no read path will block waiting for data.
|
|
fn ready_source(data: &[u8]) -> RangedHttpSource {
|
|
let total = data.len() as u64;
|
|
let buf = Arc::new(Mutex::new(data.to_vec()));
|
|
let downloaded_to = Arc::new(AtomicUsize::new(data.len()));
|
|
let done = Arc::new(AtomicBool::new(true));
|
|
let gen_arc = Arc::new(AtomicU64::new(7));
|
|
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,
|
|
gen_arc,
|
|
gen: 7,
|
|
on_demand: None,
|
|
}
|
|
}
|
|
|
|
// ── Read ──────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn read_returns_zero_when_pos_at_end() {
|
|
let mut src = ready_source(&[1, 2, 3, 4]);
|
|
src.pos = 4;
|
|
let mut out = [0u8; 8];
|
|
assert_eq!(src.read(&mut out).unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn read_returns_zero_for_empty_output_buffer() {
|
|
let mut src = ready_source(&[1, 2, 3, 4]);
|
|
let mut out: [u8; 0] = [];
|
|
assert_eq!(src.read(&mut out).unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn read_copies_full_buffer_when_data_is_already_downloaded() {
|
|
let mut src = ready_source(&[10, 20, 30, 40]);
|
|
let mut out = [0u8; 4];
|
|
assert_eq!(src.read(&mut out).unwrap(), 4);
|
|
assert_eq!(out, [10, 20, 30, 40]);
|
|
assert_eq!(src.pos, 4, "pos advances by bytes read");
|
|
}
|
|
|
|
#[test]
|
|
fn read_advances_pos_across_multiple_calls() {
|
|
let mut src = ready_source(&[1, 2, 3, 4, 5, 6]);
|
|
let mut out = [0u8; 4];
|
|
assert_eq!(src.read(&mut out).unwrap(), 4);
|
|
assert_eq!(out, [1, 2, 3, 4]);
|
|
let mut out2 = [0u8; 4];
|
|
assert_eq!(src.read(&mut out2).unwrap(), 2, "remaining is < buf.len");
|
|
assert_eq!(&out2[..2], &[5, 6]);
|
|
}
|
|
|
|
#[test]
|
|
fn read_returns_zero_when_superseded_by_gen_change() {
|
|
let mut src = ready_source(&[1, 2, 3, 4]);
|
|
src.gen_arc.store(99, Ordering::SeqCst); // generation moved on
|
|
let mut out = [0u8; 4];
|
|
assert_eq!(src.read(&mut out).unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn read_returns_partial_when_done_with_only_some_data() {
|
|
let total: u64 = 8;
|
|
let buf = Arc::new(Mutex::new(vec![0u8; total as usize]));
|
|
// Pre-fill only the first 5 bytes.
|
|
for (i, b) in [1u8, 2, 3, 4, 5].iter().enumerate() {
|
|
buf.lock().unwrap()[i] = *b;
|
|
}
|
|
let downloaded_to = Arc::new(AtomicUsize::new(5));
|
|
let done = Arc::new(AtomicBool::new(true));
|
|
let gen_arc = Arc::new(AtomicU64::new(1));
|
|
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,
|
|
gen_arc,
|
|
gen: 1,
|
|
on_demand: None,
|
|
};
|
|
let mut out = [0u8; 8];
|
|
let n = src.read(&mut out).unwrap();
|
|
assert_eq!(n, 5, "returns only the bytes that arrived before EOF");
|
|
assert_eq!(&out[..5], &[1, 2, 3, 4, 5]);
|
|
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,
|
|
on_demand: None,
|
|
};
|
|
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;
|
|
let src_buf = Arc::new(Mutex::new(vec![0u8; total as usize]));
|
|
let downloaded_to = Arc::new(AtomicUsize::new(3));
|
|
let done = Arc::new(AtomicBool::new(true));
|
|
let gen_arc = Arc::new(AtomicU64::new(1));
|
|
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,
|
|
gen_arc,
|
|
gen: 1,
|
|
on_demand: None,
|
|
};
|
|
let mut out = [0u8; 8];
|
|
assert_eq!(src.read(&mut out).unwrap(), 0);
|
|
}
|
|
|
|
// ── Seek ──────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn seek_from_start_sets_pos() {
|
|
let mut src = ready_source(&[0u8; 16]);
|
|
assert_eq!(src.seek(SeekFrom::Start(8)).unwrap(), 8);
|
|
assert_eq!(src.pos, 8);
|
|
}
|
|
|
|
#[test]
|
|
fn seek_from_start_clamps_to_total_size() {
|
|
let mut src = ready_source(&[0u8; 16]);
|
|
assert_eq!(src.seek(SeekFrom::Start(100)).unwrap(), 16);
|
|
assert_eq!(src.pos, 16);
|
|
}
|
|
|
|
#[test]
|
|
fn seek_from_current_offsets_relative_to_pos() {
|
|
let mut src = ready_source(&[0u8; 16]);
|
|
src.pos = 4;
|
|
assert_eq!(src.seek(SeekFrom::Current(3)).unwrap(), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn seek_from_current_negative_walks_backward() {
|
|
let mut src = ready_source(&[0u8; 16]);
|
|
src.pos = 10;
|
|
assert_eq!(src.seek(SeekFrom::Current(-4)).unwrap(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn seek_from_end_negative_walks_back_from_total() {
|
|
let mut src = ready_source(&[0u8; 16]);
|
|
assert_eq!(src.seek(SeekFrom::End(-3)).unwrap(), 13);
|
|
}
|
|
|
|
#[test]
|
|
fn seek_before_start_errors_with_invalid_input() {
|
|
let mut src = ready_source(&[0u8; 16]);
|
|
let err = src.seek(SeekFrom::Current(-5)).unwrap_err();
|
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
|
}
|
|
|
|
#[test]
|
|
fn seek_beyond_end_clamps_at_total_size() {
|
|
let mut src = ready_source(&[0u8; 16]);
|
|
assert_eq!(src.seek(SeekFrom::End(100)).unwrap(), 16);
|
|
}
|
|
|
|
// ── MediaSource ───────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn media_source_is_seekable_returns_true() {
|
|
let src = ready_source(&[0u8; 4]);
|
|
assert!(src.is_seekable());
|
|
}
|
|
|
|
#[test]
|
|
fn media_source_byte_len_returns_total_size() {
|
|
let src = ready_source(&[0u8; 42]);
|
|
assert_eq!(src.byte_len(), Some(42));
|
|
}
|
|
|
|
// ── ranged_http_download_loop with wiremock ──────────────────────────────
|
|
|
|
use wiremock::matchers::{header, method, path};
|
|
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
|
|
|
/// Build the loop's working set (buf, downloaded_to, gen_arc) for the given
|
|
/// total size.
|
|
fn loop_state(total: usize) -> (Arc<Mutex<Vec<u8>>>, Arc<AtomicUsize>, Arc<AtomicU64>) {
|
|
(
|
|
Arc::new(Mutex::new(vec![0u8; total])),
|
|
Arc::new(AtomicUsize::new(0)),
|
|
Arc::new(AtomicU64::new(1)),
|
|
)
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn loop_completes_full_download_on_200() {
|
|
let server = MockServer::start().await;
|
|
let body = vec![0xABu8; 4096];
|
|
Mock::given(method("GET"))
|
|
.and(path("/track"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let url = format!("{}/track", server.uri());
|
|
let client = reqwest::Client::new();
|
|
let initial = client.get(&url).send().await.unwrap();
|
|
let (buf, dl, gen_arc) = loop_state(body.len());
|
|
|
|
let (downloaded, outcome) = ranged_http_download_loop(
|
|
client,
|
|
&url,
|
|
initial,
|
|
&buf,
|
|
&dl,
|
|
1,
|
|
&gen_arc,
|
|
&PlaybackHttpHeaders::default(),
|
|
|_, _| {},
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(outcome, RangedHttpLoopOutcome::Completed);
|
|
assert_eq!(downloaded, body.len());
|
|
assert_eq!(dl.load(Ordering::SeqCst), body.len());
|
|
assert_eq!(*buf.lock().unwrap(), body);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn loop_invokes_partial_callback_per_chunk() {
|
|
let server = MockServer::start().await;
|
|
let body = vec![0u8; 1024];
|
|
Mock::given(method("GET"))
|
|
.and(path("/track"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let url = format!("{}/track", server.uri());
|
|
let client = reqwest::Client::new();
|
|
let initial = client.get(&url).send().await.unwrap();
|
|
let (buf, dl, gen_arc) = loop_state(body.len());
|
|
|
|
let calls = std::sync::Mutex::new(Vec::<(usize, usize)>::new());
|
|
let (downloaded, outcome) = ranged_http_download_loop(
|
|
client,
|
|
&url,
|
|
initial,
|
|
&buf,
|
|
&dl,
|
|
1,
|
|
&gen_arc,
|
|
&PlaybackHttpHeaders::default(),
|
|
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(outcome, RangedHttpLoopOutcome::Completed);
|
|
let calls = calls.into_inner().unwrap();
|
|
assert!(!calls.is_empty(), "on_partial must fire at least once");
|
|
let last = calls.last().unwrap();
|
|
assert_eq!(last.0, downloaded, "final call reports final downloaded count");
|
|
assert_eq!(last.1, body.len(), "total stays constant across calls");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn loop_aborts_on_initial_404() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/missing"))
|
|
.respond_with(ResponseTemplate::new(404))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let url = format!("{}/missing", server.uri());
|
|
let client = reqwest::Client::new();
|
|
let initial = client.get(&url).send().await.unwrap();
|
|
let (buf, dl, gen_arc) = loop_state(1024);
|
|
|
|
let (downloaded, outcome) =
|
|
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
|
.await;
|
|
|
|
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
|
|
assert_eq!(downloaded, 0);
|
|
assert_eq!(dl.load(Ordering::SeqCst), 0);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn loop_returns_superseded_when_gen_arc_changes_before_first_chunk() {
|
|
let server = MockServer::start().await;
|
|
// Stall the response indefinitely so the gen flip wins the race.
|
|
let body = vec![0u8; 4096];
|
|
Mock::given(method("GET"))
|
|
.and(path("/track"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_bytes(body.clone())
|
|
.set_delay(Duration::from_millis(200)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let url = format!("{}/track", server.uri());
|
|
let client = reqwest::Client::new();
|
|
let initial = client.get(&url).send().await.unwrap();
|
|
let (buf, dl, gen_arc) = loop_state(body.len());
|
|
// Flip gen_arc before any chunk arrives.
|
|
gen_arc.store(99, Ordering::SeqCst);
|
|
|
|
let (downloaded, outcome) =
|
|
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
|
.await;
|
|
|
|
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
|
|
assert!(
|
|
downloaded < body.len(),
|
|
"supersedion must short-circuit before full download (got {downloaded})"
|
|
);
|
|
}
|
|
|
|
/// Responder that returns a 200 with the first half on the first hit, then
|
|
/// expects a Range header for the second hit and returns 206 with the rest.
|
|
struct PartialThenResume {
|
|
body: Vec<u8>,
|
|
split: usize,
|
|
seen: std::sync::atomic::AtomicUsize,
|
|
}
|
|
|
|
impl Respond for PartialThenResume {
|
|
fn respond(&self, req: &Request) -> ResponseTemplate {
|
|
let nth = self.seen.fetch_add(1, Ordering::SeqCst);
|
|
if nth == 0 {
|
|
// First hit: pretend the connection drops mid-stream by returning
|
|
// only the first `split` bytes.
|
|
ResponseTemplate::new(200).set_body_bytes(self.body[..self.split].to_vec())
|
|
} else {
|
|
// Second hit must carry a Range header.
|
|
assert!(
|
|
req.headers
|
|
.get(reqwest::header::RANGE.as_str())
|
|
.is_some(),
|
|
"reconnect request must include a Range header",
|
|
);
|
|
ResponseTemplate::new(206).set_body_bytes(self.body[self.split..].to_vec())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn loop_reconnects_with_range_header_after_short_first_response() {
|
|
let server = MockServer::start().await;
|
|
let body: Vec<u8> = (0u8..200).cycle().take(8192).collect();
|
|
let split = 3000;
|
|
Mock::given(method("GET"))
|
|
.and(path("/track"))
|
|
.respond_with(PartialThenResume {
|
|
body: body.clone(),
|
|
split,
|
|
seen: std::sync::atomic::AtomicUsize::new(0),
|
|
})
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let url = format!("{}/track", server.uri());
|
|
let client = reqwest::Client::new();
|
|
let initial = client.get(&url).send().await.unwrap();
|
|
let (buf, dl, gen_arc) = loop_state(body.len());
|
|
|
|
let (downloaded, outcome) =
|
|
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
|
.await;
|
|
|
|
// Stream finishes via a Range-resumed second request.
|
|
assert!(
|
|
matches!(outcome, RangedHttpLoopOutcome::Completed | RangedHttpLoopOutcome::Aborted),
|
|
"outcome was {outcome:?}",
|
|
);
|
|
if outcome == RangedHttpLoopOutcome::Completed {
|
|
assert_eq!(downloaded, body.len());
|
|
assert_eq!(*buf.lock().unwrap(), body);
|
|
} else {
|
|
// Some wiremock setups don't actually trigger reconnect when the body
|
|
// is short — fall back to asserting at least the first half landed.
|
|
assert!(downloaded >= split);
|
|
}
|
|
}
|
|
|
|
/// Serves whatever inclusive byte range the request asks for out of `body`,
|
|
/// as a 206 — models a server that honours arbitrary `Range` requests.
|
|
struct RangeResponder {
|
|
body: Vec<u8>,
|
|
}
|
|
|
|
impl Respond for RangeResponder {
|
|
fn respond(&self, req: &Request) -> ResponseTemplate {
|
|
let range = req
|
|
.headers
|
|
.get(reqwest::header::RANGE.as_str())
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.strip_prefix("bytes="))
|
|
.map(|s| s.to_string());
|
|
let Some(range) = range else {
|
|
return ResponseTemplate::new(200).set_body_bytes(self.body.clone());
|
|
};
|
|
let mut parts = range.splitn(2, '-');
|
|
let start: usize = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
let end_inclusive: usize = parts
|
|
.next()
|
|
.filter(|s| !s.is_empty())
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(self.body.len().saturating_sub(1));
|
|
let end = (end_inclusive + 1).min(self.body.len());
|
|
ResponseTemplate::new(206).set_body_bytes(self.body[start..end].to_vec())
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn read_far_ahead_is_served_by_on_demand_range_fetch() {
|
|
// 4 MiB track; nothing downloaded linearly yet and the download is still
|
|
// "in progress" (done = false). A read whose cursor sits well past the
|
|
// linear front must be satisfied by an on-demand Range fetch.
|
|
let total: usize = 4 * 1024 * 1024;
|
|
let body: Vec<u8> = (0..total).map(|i| (i % 256) as u8).collect();
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/track"))
|
|
.respond_with(RangeResponder { body: body.clone() })
|
|
.mount(&server)
|
|
.await;
|
|
let url = format!("{}/track", server.uri());
|
|
|
|
let buf = Arc::new(Mutex::new(vec![0u8; total]));
|
|
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
|
let gen_arc = Arc::new(AtomicU64::new(1));
|
|
let on_demand = Some(Arc::new(OnDemand::new(
|
|
reqwest::Client::new(),
|
|
tokio::runtime::Handle::current(),
|
|
url,
|
|
buf.clone(),
|
|
total as u64,
|
|
gen_arc.clone(),
|
|
1,
|
|
PlaybackHttpHeaders::default(),
|
|
)));
|
|
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 as u64,
|
|
pos: 2 * 1024 * 1024, // 2 MiB — far past the (empty) linear front
|
|
done: Arc::new(AtomicBool::new(false)),
|
|
gen_arc,
|
|
gen: 1,
|
|
on_demand,
|
|
};
|
|
|
|
// The blocking read polls until the on-demand fetch fills the region.
|
|
let out = tokio::task::spawn_blocking(move || {
|
|
let mut out = [0u8; 16];
|
|
let n = src.read(&mut out).unwrap();
|
|
(n, out)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.0, 16, "read returns the requested bytes via on-demand fetch");
|
|
let base = 2 * 1024 * 1024usize;
|
|
let expected: Vec<u8> = (base..base + 16).map(|i| (i % 256) as u8).collect();
|
|
assert_eq!(&out.1[..], &expected[..]);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn ranged_write_http_range_rejects_200_at_nonzero_offset() {
|
|
// A server that ignores Range and answers 200 with the whole body must
|
|
// NOT be written at a non-zero offset (would corrupt the buffer).
|
|
let server = MockServer::start().await;
|
|
let body = vec![0xCDu8; 4096];
|
|
Mock::given(method("GET"))
|
|
.and(path("/track"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body))
|
|
.mount(&server)
|
|
.await;
|
|
let url = format!("{}/track", server.uri());
|
|
|
|
let buf = Arc::new(Mutex::new(vec![0u8; 4096]));
|
|
let gen_arc = Arc::new(AtomicU64::new(1));
|
|
let res = ranged_write_http_range(
|
|
&reqwest::Client::new(),
|
|
&url,
|
|
&buf,
|
|
1024, // non-zero offset
|
|
2047,
|
|
1,
|
|
&gen_arc,
|
|
&PlaybackHttpHeaders::default(),
|
|
)
|
|
.await;
|
|
|
|
assert!(res.is_err(), "200 at a non-zero offset must be rejected");
|
|
assert!(
|
|
buf.lock().unwrap().iter().all(|&b| b == 0),
|
|
"buffer must be left untouched on a rejected 200"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn loop_aborts_when_reconnect_returns_non_206() {
|
|
// Returns 200 first time (partial body), then 200 again (not 206) on the
|
|
// reconnect — the loop must abort.
|
|
let server = MockServer::start().await;
|
|
let body = vec![0u8; 4096];
|
|
Mock::given(method("GET"))
|
|
.and(path("/track"))
|
|
.and(header("range", "bytes=2048-"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body[2048..].to_vec()))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/track"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body[..2048].to_vec()))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let url = format!("{}/track", server.uri());
|
|
let client = reqwest::Client::new();
|
|
let initial = client.get(&url).send().await.unwrap();
|
|
let (buf, dl, gen_arc) = loop_state(body.len());
|
|
|
|
let (downloaded, outcome) =
|
|
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
|
.await;
|
|
|
|
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
|
|
// stays at 2048 (the first half from the initial request).
|
|
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
|
|
assert_eq!(downloaded, 2048);
|
|
}
|
|
}
|