Skip to content

Commit f2a8b7d

Browse files
authored
Merge pull request #560 from InstaZDLL/feat/remote-stream-cache
feat(remote): cache a remote stream from the bytes playback already reads
2 parents 3fa6125 + 556319a commit f2a8b7d

30 files changed

Lines changed: 1033 additions & 24 deletions

docs/rfcs/RFC-005-remote-source-and-sync-v2.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,34 @@ negative sentinel id at load time, per position rather than per track, and the
428428
rows are moved rather than re-projected — so the handle survives an optimistic
429429
reorder even on a playlist holding one track twice.
430430

431+
**A remote stream is cached on disk, and it is filled by playback itself.**
432+
Until now the audio was re-fetched in full on every play — the projection
433+
caches metadata and the cover cache caches covers, but the bytes that actually
434+
cost bandwidth were not kept. They are now, under
435+
`profile_remote_stream_dir`, keyed by `(track id, format, bitrate)`: the triple
436+
that determines the bytes, and deliberately not the URL, which carries a
437+
single-use ticket and differs on every play.
438+
439+
The cache is **not** a downloader. Nothing extra is fetched and nothing is
440+
delayed: every block the decoder reads is written at its **absolute offset**
441+
into a sparse working file, so the first play sounds exactly as it did and the
442+
second reads from disk. Writing by offset rather than by append is what makes
443+
this survive symphonia, which seeks while probing and again on a scrub — an
444+
append-only tee would have to give up at the first seek, which for most formats
445+
arrives within the first few kilobytes.
446+
447+
An entry is published only when the covered ranges merge into one span over the
448+
whole body, by a single atomic rename out of `.part`. A partial file is worse
449+
than an absent one: it decodes for a while and then stops, which reads as a
450+
broken track rather than as a cold cache. A body whose length the server did
451+
not declare is never cached, because completeness could not be decided. On a
452+
hit the track loads through the existing `LoadRemoteFileAndPlay` path with a
453+
freshly-minted ticket as its `fallback_url` — a small JSON round-trip, not the
454+
body the cache just saved, which buys back the decoder's repair path so a
455+
cached file that will not decode falls back to the server once instead of
456+
failing that track forever. Offline, the ticket is simply not minted and the
457+
cached file plays alone, which is the point of having it.
458+
431459
**The queue panel** switches to a dedicated `RemoteQueueView` while a remote
432460
session plays (keyed on `isRemoteTrack`), reading `remote_get_play_queue` (an
433461
in-memory snapshot) and jumping with `remote_queue_jump` — the local

src-tauri/crates/app/src/audio/decoder.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ fn decoder_loop(
434434
artist,
435435
artwork_url,
436436
fallback_url,
437+
discard_on_failure,
437438
replay_gain,
438439
} => {
439440
shared.clear_ab_loop();
@@ -507,6 +508,7 @@ fn decoder_loop(
507508
has_server_fallback = fallback_url.is_some(),
508509
"remote local source open failed"
509510
);
511+
discard_unplayable(&path, discard_on_failure);
510512
if let Some(url) = fallback_url.filter(|_| !crate::offline::is_offline()) {
511513
pending_cmd = Some(AudioCmd::LoadUrlAndPlay {
512514
url,
@@ -519,6 +521,12 @@ fn decoder_loop(
519521
// server instead of the local file must
520522
// not change its level.
521523
replay_gain,
524+
// Not cached: this is the repair path for a
525+
// file that would not open, and the target
526+
// that named it is two commands upstream.
527+
// Caching here would also risk re-writing the
528+
// very entry that just failed to decode.
529+
cache: None,
522530
});
523531
continue;
524532
}
@@ -544,6 +552,7 @@ fn decoder_loop(
544552
path = %path.display(),
545553
"remote local decode failed; falling back to server"
546554
);
555+
discard_unplayable(&path, discard_on_failure);
547556
pending_cmd = Some(AudioCmd::LoadUrlAndPlay {
548557
url,
549558
ext_hint: None,
@@ -552,6 +561,8 @@ fn decoder_loop(
552561
title,
553562
artist,
554563
artwork_url,
564+
// See above: a repair path does not repopulate.
565+
cache: None,
555566
});
556567
continue;
557568
}
@@ -572,6 +583,7 @@ fn decoder_loop(
572583
artist,
573584
artwork_url,
574585
replay_gain,
586+
cache,
575587
} => {
576588
tracing::info!(
577589
track_id,
@@ -685,7 +697,15 @@ fn decoder_loop(
685697
// with the live `StreamTitle` while keeping the station's
686698
// cover + name; it stays forward-only.
687699
let opened = if is_remote {
688-
super::http_source::HttpMediaSource::open_seekable(&url)
700+
// A cache target only ever accompanies a finite
701+
// remote-queue track, so the seekable open is the only
702+
// one that can honour it.
703+
match cache {
704+
Some(target) => {
705+
super::http_source::HttpMediaSource::open_seekable_caching(&url, target)
706+
}
707+
None => super::http_source::HttpMediaSource::open_seekable(&url),
708+
}
689709
} else {
690710
let icy_ctx = super::http_source::IcyContext {
691711
app: app.clone(),
@@ -2423,3 +2443,21 @@ mod tests {
24232443
approx(ro, 2.0 + K * (0.4 + 0.8 + 0.2 + 0.07));
24242444
}
24252445
}
2446+
2447+
/// Drop a reproducible copy that would not play.
2448+
///
2449+
/// Only ever called with `allowed` set for a stream-cache entry: the server
2450+
/// still holds those bytes, so a file that fails to open or decode is worth
2451+
/// losing to make the next play refetch it. A reconciled file from the user's
2452+
/// own library reaches the same failure path and must survive it — it is
2453+
/// theirs, and a decoder that deletes a listener's music because a codec
2454+
/// tripped would be a far worse bug than a cache miss.
2455+
fn discard_unplayable(path: &std::path::Path, allowed: bool) {
2456+
if !allowed {
2457+
return;
2458+
}
2459+
match std::fs::remove_file(path) {
2460+
Ok(()) => tracing::info!(path = %path.display(), "dropped unplayable cached stream"),
2461+
Err(err) => tracing::warn!(?err, path = %path.display(), "could not drop cached stream"),
2462+
}
2463+
}

src-tauri/crates/app/src/audio/engine.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ pub enum AudioCmd {
5959
artist: Option<String>,
6060
artwork_url: Option<String>,
6161
fallback_url: Option<String>,
62+
/// Whether `path` is a reproducible copy that should be discarded if
63+
/// it will not open or decode.
64+
///
65+
/// True for a stream-cache entry, which can always be fetched again;
66+
/// false for a reconciled file from the user's own library, which is
67+
/// theirs and is never ours to delete. Without the distinction a bad
68+
/// cache entry would fall back to the server on every single play
69+
/// instead of once.
70+
discard_on_failure: bool,
6271
replay_gain: TrackGain,
6372
},
6473
Pause,
@@ -118,6 +127,10 @@ pub enum AudioCmd {
118127
title: Option<String>,
119128
artist: Option<String>,
120129
artwork_url: Option<String>,
130+
/// Where to cache the bytes this stream reads, when it is a finite
131+
/// remote-queue track worth keeping. `None` for radio, which is
132+
/// endless and therefore never complete.
133+
cache: Option<crate::audio::stream_cache::CacheTarget>,
121134
/// Loudness metadata for the stream. `TrackGain::default()`
122135
/// for a live radio station — nothing knows anything about it
123136
/// — but a library track that fell back to streaming from the
@@ -404,13 +417,18 @@ impl RadioResumeState {
404417
artist: self.artist,
405418
artwork_url: self.artwork_url,
406419
replay_gain,
420+
// Resuming radio after a device change: endless, so never a
421+
// complete body to cache.
422+
cache: None,
407423
},
408424
RadioResumeSource::RemoteFile {
409425
path,
410426
duration_ms,
411427
fallback_url,
412428
replay_gain,
413429
} => AudioCmd::LoadRemoteFileAndPlay {
430+
// Radio resume never restores a cache entry.
431+
discard_on_failure: false,
414432
path,
415433
start_ms: position_ms,
416434
track_id: self.track_id,
@@ -1499,6 +1517,10 @@ fn apply_radio_resume_update(snapshot: &Mutex<Option<RadioResumeState>>, cmd: &A
14991517
artist,
15001518
artwork_url,
15011519
replay_gain,
1520+
// The resume snapshot exists to restart radio after a device
1521+
// change; a cache target belongs to one open response and does
1522+
// not survive into a new one.
1523+
cache: _,
15021524
} => {
15031525
if let Ok(mut guard) = snapshot.lock() {
15041526
*guard = Some(RadioResumeState {
@@ -1515,6 +1537,7 @@ fn apply_radio_resume_update(snapshot: &Mutex<Option<RadioResumeState>>, cmd: &A
15151537
}
15161538
}
15171539
AudioCmd::LoadRemoteFileAndPlay {
1540+
discard_on_failure: false,
15181541
path,
15191542
duration_ms,
15201543
fallback_url,
@@ -1699,6 +1722,7 @@ mod radio_resume_tests {
16991722
title: Some("Test stream".to_string()),
17001723
artist: Some("Test artist".to_string()),
17011724
artwork_url: Some("https://example.invalid/art.jpg".to_string()),
1725+
cache: None,
17021726
replay_gain: TrackGain::default(),
17031727
}
17041728
}
@@ -1725,6 +1749,7 @@ mod radio_resume_tests {
17251749
artist: Some("Remote artist".to_string()),
17261750
artwork_url: None,
17271751
fallback_url: fallback_url.map(str::to_string),
1752+
discard_on_failure: false,
17281753
replay_gain: TrackGain {
17291754
gain_db: Some(-4.0),
17301755
peak: None,

src-tauri/crates/app/src/audio/http_source.rs

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ pub struct HttpMediaSource {
174174
/// (radio servers resend the same title every interval) doesn't
175175
/// re-fire the event.
176176
last_title: Option<String>,
177+
/// Writes what this source reads into the on-disk stream cache, when the
178+
/// caller asked for it and the body declared a length. `None` for radio
179+
/// (endless, so never complete) and whenever the cache is unusable — it
180+
/// is an optimisation, and its absence is never a playback failure.
181+
cache: Option<crate::audio::stream_cache::CacheWriter>,
177182
}
178183

179184
impl HttpMediaSource {
@@ -182,15 +187,15 @@ impl HttpMediaSource {
182187
/// 404 / 502 surfaces as `Err` instead of producing a `MediaSource`
183188
/// that would only fail at the first `probe()` call.
184189
pub fn open(url: &str) -> Result<Self, String> {
185-
Self::open_inner(url, None, false)
190+
Self::open_inner(url, None, false, None)
186191
}
187192

188193
/// Open a streaming HTTP GET that also requests + de-interleaves ICY
189194
/// metadata, re-emitting `player:radio-metadata` through `icy.app`
190195
/// whenever the live `StreamTitle` changes. Falls back transparently
191196
/// to passthrough when the server ignores `Icy-MetaData: 1`.
192197
pub fn open_with_icy(url: &str, icy: IcyContext) -> Result<Self, String> {
193-
Self::open_inner(url, Some(icy), false)
198+
Self::open_inner(url, Some(icy), false, None)
194199
}
195200

196201
/// Open a finite, range-capable file for **seekable** playback — a
@@ -200,10 +205,29 @@ impl HttpMediaSource {
200205
/// drive a real `format.seek`. Degrades to forward-only if the server
201206
/// doesn't answer ranges — playback still works, only scrubbing won't.
202207
pub fn open_seekable(url: &str) -> Result<Self, String> {
203-
Self::open_inner(url, None, true)
208+
Self::open_inner(url, None, true, None)
204209
}
205210

206-
fn open_inner(url: &str, icy: Option<IcyContext>, want_seek: bool) -> Result<Self, String> {
211+
/// [`Self::open_seekable`], additionally filling the on-disk stream cache
212+
/// from the bytes playback reads.
213+
///
214+
/// Nothing extra is fetched and nothing is delayed: the cache is written
215+
/// from the blocks the decoder was going to read anyway, at their absolute
216+
/// offsets, so a seek leaves a hole rather than corrupting the file and
217+
/// the entry is simply never published.
218+
pub fn open_seekable_caching(
219+
url: &str,
220+
target: crate::audio::stream_cache::CacheTarget,
221+
) -> Result<Self, String> {
222+
Self::open_inner(url, None, true, Some(target))
223+
}
224+
225+
fn open_inner(
226+
url: &str,
227+
icy: Option<IcyContext>,
228+
want_seek: bool,
229+
cache_target: Option<crate::audio::stream_cache::CacheTarget>,
230+
) -> Result<Self, String> {
207231
// Offline short-circuit at the HTTP boundary itself. The decoder
208232
// already gates `LoadUrlAndPlay` on this before reaching here, but
209233
// guarding the source too makes it self-honouring for any future
@@ -269,6 +293,12 @@ impl HttpMediaSource {
269293
// `byte_len() == None` exactly as before, even on the rare stream
270294
// that sends a Content-Length, so symphonia never treats it as
271295
// finite.
296+
// Two different questions were sharing one variable. Seeking needs a
297+
// length AND ranges; caching needs only a length, because it is filled
298+
// by reading forward. Collapsing them meant a server that sends
299+
// `Content-Length` without `Accept-Ranges` — perfectly cacheable —
300+
// was never cached at all.
301+
let declared_len = len;
272302
let len = if seekable { len } else { None };
273303

274304
Ok(Self {
@@ -285,6 +315,14 @@ impl HttpMediaSource {
285315
// actually parse blocks).
286316
icy: if metaint > 0 { icy } else { None },
287317
last_title: None,
318+
// Only a body that declared its length can be cached: without one
319+
// there is no way to decide that every byte has been covered, and
320+
// a file we cannot call complete must never be offered as one.
321+
cache: cache_target.and_then(|target| {
322+
declared_len.and_then(|len| {
323+
crate::audio::stream_cache::CacheWriter::create(&target.dir, &target.name, len)
324+
})
325+
}),
288326
})
289327
}
290328

@@ -406,7 +444,17 @@ impl Read for HttpMediaSource {
406444
// byte cursor here for `SeekFrom::Current`.
407445
if self.metaint == 0 {
408446
let n = guard.read(buf)?;
447+
drop(guard);
448+
let start = self.pos;
409449
self.pos += n as u64;
450+
// Record where these bytes live, not merely that they arrived: a
451+
// seek reopens the body at another offset, so an append would
452+
// interleave two regions into one corrupt file.
453+
if n > 0 {
454+
if let Some(cache) = self.cache.as_mut() {
455+
cache.write_at(start, &buf[..n]);
456+
}
457+
}
410458
return Ok(n);
411459
}
412460
if buf.is_empty() {

src-tauri/crates/app/src/audio/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub mod replay_gain;
3636
pub mod resampler;
3737
pub mod spectrum;
3838
pub mod state;
39+
pub mod stream_cache;
3940
#[cfg(target_os = "windows")]
4041
pub mod wasapi_exclusive;
4142

0 commit comments

Comments
 (0)