Files
SoulStream/src-tauri/src/playback.rs
T
KeelrLeek 9f7661afab android auto: the car's queue works like the page's, and every row gets its cover
"Needs the app" is gone. It named what the cold service could not do — the
resolver and the Soulseek session live in the page, and a car that starts
the app cold has no page — and Kyle's answer to the label was the right
one: a tap should find a copy and play it, in the background, and a
playlist should keep going through rows that are not here exactly as the
page keeps going.

headless_slsk.rs is a Soulseek session for a process with no page: the
stored account, one search (artist + simplified title, the server's
excluded phrases stripped), copies ranked by rules close to the page's
songCandidates — the title in the filename, streamable formats, a duration
within eight per cent, lossless over 320 over the rest, the artist in the
path, a free upload slot before anything else — and a download into the
same stream cache the page uses. Its own tokio runtime: a car-started
process has no Tauri one.

headless.rs is the page's queue in Rust. resolve_playable queues EVERY row
of a playlist in order, a row not here with its remembered peer file and
no path. The engine starts a row from its file when it is whole, from the
fetch already filling its cache file when one is, and otherwise finds it
on the network right then (the remembered peer first, then the search),
playing the bytes as they land; a peer that sends nothing for fifteen
seconds is dropped for the next copy, twice, and when a row has no copy
anywhere the queue moves on — only the last row's failure reaches the
card. The next three rows are fetched ahead of time, four seconds after a
start so nothing competes with the track that is starting, and when the
immediate next is whole before the current one ends it is appended to the
sink and the boundary is gapless — the page's preloadLocal and
preloadCachedStream in one. Fetches are keyed by cache file, not index, so
a shuffle cannot start the same file from the same peer twice. The card
shows the title at once with a spinner (STATE_BUFFERING through
headlessState), then the track; every source is remembered in the mirror,
so the row is "here" next time.

One session per process: when the app opens over it, login_inner ADOPTS
the client and reads its events through subscribe() instead of logging in
again — two logins from one address in quick succession is what the server
throttles — and the fetches in flight become the page's own preloads, so
the row the car was fetching ahead lands gaplessly under the page too.

Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have
no catalog cover, and the car drew the app icon over them. In the cold
process the cover embedded in the file is extracted — the desktop's own
tagger::extract_art, keyed by the audio path under
<library>/.soulstream/stream-art/by-file/, a miss remembered as an empty
file — for browse rows, the card and the Continue row, and a row fetched
cold gets the same once 200 kB of it have landed. On the phone,
coverart.svelte.js is the desktop's playlist backfill as a background walk
over every playlist: the file before the catalog (the row's own cached
copy, persisted as art_path), then the ONE strict ladder the import review
uses (findCatalogTrack, persisted as art_url + track_id) — never a loose
name lookup. After boot, on every playlist change, every six hours, the
open page's rows first, a miss remembered for a day and only when the
catalog answered. The player does the same for the track that is playing
(ensureStreamArt: embedded, then catalog), makes the URL the entry's art
key and hands it to the walk to persist. The page's own hole is closed
too: a streamed row with no cover got no art key at all, and where it had
one the extractor's FILE PATH was stored where a data URL belongs; such
rows get a "stream:" key and the file is read as a thumbnail, and a stream
row's art_path now draws on the phone.

A car that starts the app cold has no page to walk for it, so the cold
process asks the catalog itself. cover_hunt.rs is catalogMatch.js in
Rust — the same Deezer field queries through the metadata crate, a match
only when the artist agrees, the base title agrees, the version
qualifiers are compatible and a known duration does not contradict; the
two are meant to stay in step — fed by every browse and every queue built
from a playlist. A blank stream row is answered from what was asked
before (stream-art/catalog.json, a miss for a day and only when the
catalog answered), else queued for one thread that looks rows up one at
a time behind the client's own Deezer pacing, writes the answer on the
row (art_url and the track id, only ever filling a NULL; the library is
WAL and the page's writer is another connection), and re-lists the
playlist on the car. The track that is playing gets the same, through an
entry that moves only the card's art and never its position. The cold
Soulseek search is the page's two-rung ladder as well: artist plus the
simplified title, then the title alone with the artist required in the
peer's path; the hunt's last rung is the page's last rung too, the
artist's whole shelf flattened to tracks, for the records the search
index buries — spent only on the track that is playing, bounded to forty
seconds, and the cover memory carries the matcher's version so a "no"
from an older matcher is no answer once a rung is added.

Two things the head unit found in the seam. media3's default onSubscribe
answers "not supported", so nobody was ever subscribed and the re-list
notice reached nobody; LibraryTree accepts subscriptions now, and covers
land on the car's list within seconds of being found. And the Soulseek
client spawns a download's transfer task with tokio::spawn, which needs a
runtime on the calling thread — the engine's resolver thread is not a
Tauri command, the spawn panicked, the thread died without a word, and
the card sat on BUFFERING; every client call from an engine thread runs
under the cold runtime's enter() now.

The phone's artist page never ran the MusicBrainz pass. It built its
discography from Deezer's list alone, so the releases Deezer's artist
listing omits — EPs, and whole albums — never appeared, nor the
authoritative release types or the guest-appearance and co-tenant
arbitration. The desktop's identity merge is a plain module now
(lib/artistIdentity.js, moved out of state.svelte.js verbatim, the
desktop importing it back), and the phone runs it after the page is on
screen — one implementation for both surfaces.

What the restarted head unit found, all in the seam between a car session
and the page — and one bug older than this branch.

The pump's calls into Kotlin never worked. "auto advanced to" has not
appeared in a single log while cold queues advanced through whole albums
under a card stuck on track one, and the new end-of-queue notice did not
fire either. FindClass on the self-attached pump thread resolves through
the system class loader, which cannot see com.soulstream.mediasession
.AutoNative; the lookup failed and the exception was cleared, silently,
every time. The class is taken from the object Kotlin hands every JNI
entry — on a Java thread, through the app's own loader — and kept as a
global reference; every pump call goes through call_auto_native.

Autoplay on connection worked unprompted and resumed Starboy at 3:50 of
3:50: the page writes the position one last time when a track runs out, so
a resume point can sit at the end. resume_queue treats a position within
two seconds of the duration as "the next item from the top", keeps every
entry (one not here is found on reach, like any other), and the headless
engine reports end-of-queue to Kotlin, which turns the card to paused. A
seek on the page reached the resume point only at the pump's next
five-second flush; seekSecs writes the session at once.

Opening the app over a cold car session handed the page the right queue
and the page called it a paused session that needed re-sourcing: the
transport snapshot it reads is built only from events after the page
subscribes, and an engine already playing sends no Playing. The bar said
paused, the card said PAUSED, the audio kept going. playback::init seeds
the snapshot from the engine's own progress when it adopts a running
engine, so the page follows what is sounding.

preview::init opened two more output streams at every launch for the
desktop's hover previews, which the phone never calls — three live AAudio
tracks on our uid with one sounding. It is gated off on Android.
2026-09-18 15:00:11 -05:00

603 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Playback orchestration: bridges the audio thread, the streaming cache,
//! and the webview.
use serde::Serialize;
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
static CACHE_PRUNE_LOCK: Mutex<()> = Mutex::new(());
use std::time::{Duration, Instant};
use tauri::{Emitter, Manager};
use player::{Player, PlayerEvent, StreamProgress};
// The audio thread keeps its responsive 60 ms command/end-detection loop, but
// the webview does not need 16-17 IPC/reactivity updates per second to move a
// progress indicator. Roughly four to five updates per second remain visually
// smooth while letting Chromium spend most playback time asleep.
const UI_POSITION_INTERVAL: Duration = Duration::from_millis(200);
const WAVEFORM_CACHE_MAX_ENTRIES: usize = 256;
const PROGRESSIVE_WAVEFORM_CACHE_MAX_ENTRIES: usize = 64;
const WAVEFORM_JOB_MAX_IDLE_ENTRIES: usize = 256;
/// Tiny insertion-order LRU used for decoded waveform data. Waveforms are
/// cheap individually, but a path-keyed session cache must not grow with every
/// track ever visited.
struct WaveformLru<T> {
entries: HashMap<String, T>,
order: VecDeque<String>,
capacity: usize,
}
impl<T: Clone> WaveformLru<T> {
fn new(capacity: usize) -> Self {
Self {
entries: HashMap::new(),
order: VecDeque::new(),
capacity,
}
}
fn get(&mut self, key: &str) -> Option<T> {
let value = self.entries.get(key)?.clone();
self.touch(key);
Some(value)
}
fn insert(&mut self, key: String, value: T) {
self.entries.insert(key.clone(), value);
self.touch(&key);
while self.entries.len() > self.capacity {
let Some(oldest) = self.order.pop_front() else {
break;
};
self.entries.remove(&oldest);
}
}
fn remove(&mut self, key: &str) {
self.entries.remove(key);
if let Some(index) = self.order.iter().position(|candidate| candidate == key) {
self.order.remove(index);
}
}
fn touch(&mut self, key: &str) {
if let Some(index) = self.order.iter().position(|candidate| candidate == key) {
self.order.remove(index);
}
self.order.push_back(key.to_string());
}
}
#[derive(Clone)]
pub(crate) struct ProgressiveWaveform {
decoded_bytes: u64,
next_decode_bytes: u64,
peaks: Vec<f32>,
}
impl ProgressiveWaveform {
pub(crate) fn new(decoded_bytes: u64, total_bytes: u64, peaks: Vec<f32>) -> Self {
Self {
decoded_bytes,
next_decode_bytes: next_progressive_waveform_threshold(decoded_bytes, total_bytes),
peaks,
}
}
/// Recompute only after the growing file has at least doubled. If a
/// transfer restarted and the file shrank, force a fresh decode instead of
/// returning peaks from bytes that no longer exist.
pub(crate) fn should_recompute(&self, available_bytes: u64) -> bool {
available_bytes < self.decoded_bytes
|| (available_bytes > self.decoded_bytes && available_bytes >= self.next_decode_bytes)
}
pub(crate) fn peaks(&self) -> Vec<f32> {
self.peaks.clone()
}
/// How much of the file these peaks were decoded from — the numerator of
/// the coverage the UI anchors its rise frontier to.
pub(crate) fn decoded_bytes(&self) -> u64 {
self.decoded_bytes
}
}
/// When the next partial re-decode is due. Early on the file doubles between
/// decodes — they're cheap and the shape fills in fast. But pure doubling made
/// the LAST gap span half the transfer (50% → 100%), so the drawn wave sat up
/// to 2× behind the buffer and the playhead visibly walked off its end
/// (decoded audio is always ahead of playback, the DRAWING wasn't). Past the
/// eighth-of-file mark the schedule switches to fixed total/8 steps: the wave
/// is never more than 12.5% behind the transfer, for at most ~7 extra decodes
/// spread across the download.
fn next_progressive_waveform_threshold(decoded_bytes: u64, total_bytes: u64) -> u64 {
let doubled = decoded_bytes
.saturating_mul(2)
.max(decoded_bytes.saturating_add(1));
let linear_step = decoded_bytes.saturating_add((total_bytes / 8).max(1));
doubled
.min(linear_step)
.min(total_bytes.max(decoded_bytes.saturating_add(1)))
}
pub struct PlayerState {
pub player: Player,
/// Active stream downloads: download id → progress shared with the player.
pub streams: Mutex<HashMap<u64, Arc<StreamProgress>>>,
/// Cache target for each active stream. This lets a library save adopt an
/// in-flight stream instead of requesting the same peer/path again.
pub stream_caches: Mutex<HashMap<u64, PathBuf>>,
/// Bounded completed-waveform LRU, keyed by file path + bucket count.
waveforms: Mutex<WaveformLru<Vec<f32>>>,
/// Last useful partial decode per active/recent stream. A growing file is
/// decoded again only after its byte length doubles.
progressive_waveforms: Mutex<WaveformLru<ProgressiveWaveform>>,
/// Per-key single-flight locks. The owned guard moves into the blocking
/// decoder so cancellation of the IPC future cannot expose overlapping
/// work while that decoder is still running.
waveform_jobs: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// Pre-buffered next-track downloads: download id → cache target.
/// Their events are swallowed (no UI, no library pipeline); play_stream
/// attaches to them instead of restarting the transfer.
pub preloads: Mutex<HashMap<u64, PathBuf>>,
/// Transfers whose completed cache file should also be organized into the
/// library. A save can subscribe to a stream/preload already in progress.
pub deferred_saves: Mutex<HashMap<u64, PathBuf>>,
/// The last transport state forwarded to the webview.
///
/// The audio thread is send-only — it reports by event and answers no
/// questions — so a webview that RELOADS has no way to learn that audio is
/// still running. It kept receiving position ticks and drew a moving
/// scrubber under the words "Nothing playing", because every other part of
/// the player store had been wiped with the page. This is the answer to
/// "what is the audio thread doing right now", mirrored here as the events
/// go past and read back by `player_state` at boot.
pub last_transport: Mutex<PlaybackSnapshot>,
/// The queue a CAR was driving when the app came up over it (Android
/// only; always `None` elsewhere). `init` takes it off the headless
/// engine at the moment the page takes the queue over, and the page's
/// `restoreSession` builds its queue from it instead of from its own
/// localStorage — which would describe an older session and put the
/// wrong title over the right audio. Read once through `player_state`.
pub handover: Mutex<Option<crate::resume::ResumePoint>>,
}
/// What the audio thread is doing, as of its last event. Deliberately only
/// TRANSPORT: which song this is, and the queue around it, are the frontend's
/// own knowledge and are restored from its session snapshot.
#[derive(Serialize, Clone, Default)]
pub struct PlaybackSnapshot {
/// "idle" | "buffering" | "playing" | "paused" — the same vocabulary the
/// frontend's `player.status` uses, so a restore is a direct assignment.
pub status: String,
pub position: f64,
pub duration: Option<f64>,
}
#[derive(Serialize, Clone)]
#[serde(tag = "kind", rename_all = "kebab-case")]
enum PlayerUiEvent {
Buffering,
Playing,
Paused,
Stopped,
TrackEnded { gapless: bool },
Position { secs: f64, duration: Option<f64> },
Error { message: String },
}
/// Create the audio thread and forward its events to the webview.
pub fn init(app: &tauri::AppHandle) {
let output_device = crate::config::load(app).audio_output_device;
/* ANDROID TAKES THE PROCESS'S ONE ENGINE, it does not make a second.
A car can start this app by binding the media service, play a record
into a process with no Activity, and have the user open the app on top
of it minutes later — and `Player::spawn` opens an output stream, so a
second one here is two AAudio streams playing over each other out of one
process. `headless::audio` builds it on first use, whoever gets there
first, and hands back the same one afterwards.
The engine's event receiver is single-consumer and the headless pump
already owns it, so the UI subscribes rather than reading it directly;
and the fallback queue is cleared, because from here the JS decides what
plays next and two things advancing one engine would skip a track each
time. Nothing about the desktop changes. */
#[cfg(target_os = "android")]
let (player, events, handover, first) = {
if let Ok(dir) = crate::paths::app_data(app) {
crate::headless::set_data_dir(&dir);
}
let audio = crate::headless::audio(output_device.clone());
let (tx, rx) = std::sync::mpsc::channel();
audio.subscribe(tx);
// If a car was driving this engine, the page is about to take over a
// queue it has never heard of. Hand it the queue BEFORE clearing it,
// so the page can pick it up where it is rather than restoring an
// older session of its own over live audio.
let handover = if audio.driving() { audio.resume_point() } else { None };
audio.clear_queue();
// What the engine is doing RIGHT NOW, for a page that is about to
// ask. The snapshot below is otherwise built from the events that
// arrive after this subscription — and an engine a car started was
// playing before there was anyone to tell, so no Playing ever
// arrives: the page read "idle", called the handover a paused
// session that needed re-sourcing, and pushed PAUSED to the card
// over audio that kept going (2026-09-18, Strictly Acid at 21 s).
let (position, sounding) = audio.progress();
let first = PlaybackSnapshot {
status: if sounding { "playing".into() } else { "idle".into() },
position: if sounding { position } else { 0.0 },
duration: None,
};
(audio.player.clone(), rx, handover, first)
};
#[cfg(not(target_os = "android"))]
let (player, events, handover, first) = {
let (p, e) = Player::spawn_with_device(output_device);
(
p,
e,
None,
PlaybackSnapshot {
status: "idle".into(),
..Default::default()
},
)
};
app.manage(PlayerState {
handover: Mutex::new(handover),
player,
streams: Mutex::new(HashMap::new()),
stream_caches: Mutex::new(HashMap::new()),
waveforms: Mutex::new(WaveformLru::new(WAVEFORM_CACHE_MAX_ENTRIES)),
progressive_waveforms: Mutex::new(WaveformLru::new(PROGRESSIVE_WAVEFORM_CACHE_MAX_ENTRIES)),
waveform_jobs: Mutex::new(HashMap::new()),
preloads: Mutex::new(HashMap::new()),
deferred_saves: Mutex::new(HashMap::new()),
last_transport: Mutex::new(first),
});
let handle = app.clone();
std::thread::Builder::new()
.name("player-events".into())
.spawn(move || {
let mut last_position_emit = Instant::now() - UI_POSITION_INTERVAL;
for ev in events {
let ui = match ev {
PlayerEvent::Buffering => {
last_position_emit = Instant::now() - UI_POSITION_INTERVAL;
PlayerUiEvent::Buffering
}
PlayerEvent::Playing => {
last_position_emit = Instant::now() - UI_POSITION_INTERVAL;
PlayerUiEvent::Playing
}
PlayerEvent::Paused => PlayerUiEvent::Paused,
PlayerEvent::Stopped => PlayerUiEvent::Stopped,
PlayerEvent::TrackEnded { gapless } => {
last_position_emit = Instant::now() - UI_POSITION_INTERVAL;
PlayerUiEvent::TrackEnded { gapless }
}
PlayerEvent::Position { secs, duration } => {
let now = Instant::now();
if now.duration_since(last_position_emit) < UI_POSITION_INTERVAL {
continue;
}
last_position_emit = now;
PlayerUiEvent::Position { secs, duration }
}
PlayerEvent::Error(message) => PlayerUiEvent::Error { message },
};
// Mirror the transport before emitting, so a webview that
// reloads mid-track can ask what it missed. TrackEnded is
// deliberately not recorded: the frontend owns the queue and
// decides what follows, and the next Buffering/Stopped says
// what actually happened.
// The OS media card rides this same mirror (see smtc.rs). The
// snapshot IS the transport the UI is about to be told about,
// so the card cannot drift from the window, and driving it
// from here costs no IPC at all. Copied out rather than used
// under the lock: the card's updates are cross-process calls
// and have no business holding the transport mutex.
let mut card = None;
if let Some(state) = handle.try_state::<PlayerState>() {
if let Ok(mut last) = state.last_transport.lock() {
match &ui {
PlayerUiEvent::Buffering => {
last.status = "buffering".into();
last.position = 0.0;
}
PlayerUiEvent::Playing => last.status = "playing".into(),
PlayerUiEvent::Paused => last.status = "paused".into(),
PlayerUiEvent::Stopped | PlayerUiEvent::Error { .. } => {
*last = PlaybackSnapshot {
status: "idle".into(),
..Default::default()
};
}
PlayerUiEvent::Position { secs, duration } => {
last.position = *secs;
if duration.is_some() {
last.duration = *duration;
}
}
PlayerUiEvent::TrackEnded { .. } => {}
}
card = Some((last.status.clone(), last.position, last.duration));
}
}
if let Some((status, position, duration)) = card {
crate::smtc::set_status(&handle, &status);
crate::smtc::set_timeline(&handle, position, duration);
// The taskbar preview's middle button wears play or pause
// off the same truth.
crate::thumbbar::set_playing(&handle, status == "playing");
}
let _ = handle.emit("soulseek:player", ui);
}
})
.expect("spawn player event forwarder");
}
impl PlayerState {
pub(crate) fn cached_waveform(&self, key: &str) -> Option<Vec<f32>> {
self.waveforms.lock().unwrap().get(key)
}
pub(crate) fn cache_waveform(&self, key: String, peaks: Vec<f32>) {
self.waveforms.lock().unwrap().insert(key, peaks);
}
pub(crate) fn remove_cached_waveform(&self, key: &str) {
self.waveforms.lock().unwrap().remove(key);
}
pub(crate) fn progressive_waveform(&self, key: &str) -> Option<ProgressiveWaveform> {
self.progressive_waveforms.lock().unwrap().get(key)
}
pub(crate) fn cache_progressive_waveform(&self, key: String, waveform: ProgressiveWaveform) {
self.progressive_waveforms
.lock()
.unwrap()
.insert(key, waveform);
}
pub(crate) fn remove_progressive_waveform(&self, key: &str) {
self.progressive_waveforms.lock().unwrap().remove(key);
}
pub(crate) fn waveform_job(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
let mut jobs = self.waveform_jobs.lock().unwrap();
if jobs.len() >= WAVEFORM_JOB_MAX_IDLE_ENTRIES {
// An entry with only the map's Arc is idle and safe to forget.
// Active/waiting jobs may temporarily exceed the soft bound, but
// they must keep their shared lock until their decoder exits.
let idle: Vec<String> = jobs
.iter()
.filter(|(_, job)| Arc::strong_count(job) == 1)
.map(|(key, _)| key.clone())
.collect();
for idle_key in idle {
if jobs.len() < WAVEFORM_JOB_MAX_IDLE_ENTRIES {
break;
}
jobs.remove(&idle_key);
}
}
jobs.entry(key.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
pub(crate) fn release_waveform_job(&self, key: &str, job: &Arc<tokio::sync::Mutex<()>>) {
let mut jobs = self.waveform_jobs.lock().unwrap();
if jobs
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, job) && Arc::strong_count(current) == 2)
{
jobs.remove(key);
}
}
}
/// The stream cache under an instance's data directory. Pure, so a process
/// that has no `AppHandle` (the media service, cold) reaches the same
/// files the app does.
pub fn cache_dir_in(data_dir: &std::path::Path) -> PathBuf {
data_dir.join("stream-cache")
}
/// Stable cache filename for a remote file: short hash + original name.
pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) -> Option<PathBuf> {
let dir = cache_dir_in(&crate::paths::app_data(app).ok()?);
std::fs::create_dir_all(&dir).ok()?;
Some(cache_file_in(&dir, username, remote_path))
}
/// The same name, computed without touching the disk. `stream_sources.rs`
/// uses it to ask whether a remembered peer file is already here.
pub fn cache_file_in(dir: &std::path::Path, username: &str, remote_path: &str) -> PathBuf {
let mut hash: u64 = 0xcbf29ce484222325;
for b in username.bytes().chain(remote_path.bytes()) {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
let name = remote_path.rsplit('\\').next().unwrap_or(remote_path);
let safe: String = name
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
dir.join(format!("{hash:016x} {safe}"))
}
/// The in-flight download target for a cache file: `<name>.<ext>.partial`.
pub fn partial_path(cache: &std::path::Path) -> PathBuf {
cache.with_extension(format!(
"{}partial",
cache
.extension()
.map(|e| format!("{}.", e.to_string_lossy()))
.unwrap_or_default()
))
}
/// Enforce the configured streamed-track retention on the cache: drop files
/// not played within the retention window, then oldest-first beyond the size
/// cap. The playing track's file is held open by the OS, so its delete fails
/// and it is simply kept.
pub fn prune_cache(app: &tauri::AppHandle) {
// Completion/settings paths can request pruning concurrently. Serialize
// their directory snapshots so a stale running total cannot over-delete.
let _prune_guard = CACHE_PRUNE_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let cfg = crate::config::load(app);
let max_bytes = crate::config::stream_cache_max_mb(&cfg) * 1024 * 1024; // 0 = unlimited
let retention = std::time::Duration::from_secs(
crate::config::stream_retention_days(&cfg) as u64 * 24 * 60 * 60,
);
// In keep-forever mode streams land in the library, so the cache is only
// a transfer buffer — age expiry off, size cap still applies.
let age_prune = !crate::config::stream_keep_forever(&cfg);
let Ok(data_dir) = crate::paths::app_data(app) else {
return;
};
let dir = cache_dir_in(&data_dir);
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
// A track fetched because its playlist is kept offline is not a cache
// entry, it is the offline copy — the retention window and the size cap
// both leave it alone, or "keep offline" would quietly stop being true.
let kept = crate::stream_sources::kept_cache_files(&data_dir);
let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
.flatten()
.filter_map(|e| {
let path = e.path();
if path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("partial"))
{
// Active downloads are transfer buffers, not evictable cache
// entries. Deleting one can make its final rename fail.
return None;
}
if kept.contains(&path) {
return None;
}
let md = e.metadata().ok()?;
if !md.is_file() {
return None;
}
Some((md.modified().ok()?, md.len(), path))
})
.collect();
files.sort_by_key(|(t, ..)| *t);
let now = std::time::SystemTime::now();
let mut total: u64 = files.iter().map(|(_, len, _)| len).sum();
let mut kept = Vec::new();
for (modified, len, path) in files {
let expired = age_prune
&& now
.duration_since(modified)
.map(|age| age > retention)
.unwrap_or(false);
if expired && std::fs::remove_file(&path).is_ok() {
total -= len;
continue;
}
kept.push((len, path));
}
if max_bytes == 0 {
return;
}
for (len, path) in kept {
if total <= max_bytes {
break;
}
if std::fs::remove_file(&path).is_ok() {
total -= len;
}
}
}
/// Mark a cache file as freshly used so retention counts from the last play,
/// not the first stream.
pub fn touch(path: &std::path::Path) {
if let Ok(f) = std::fs::File::options().write(true).open(path) {
let _ = f.set_modified(std::time::SystemTime::now());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn waveform_lru_evicts_oldest_and_refreshes_hits() {
let mut cache = WaveformLru::new(2);
cache.insert("a".into(), vec![1.0]);
cache.insert("b".into(), vec![2.0]);
assert_eq!(cache.get("a"), Some(vec![1.0]));
cache.insert("c".into(), vec![3.0]);
assert_eq!(cache.get("b"), None);
assert_eq!(cache.get("a"), Some(vec![1.0]));
assert_eq!(cache.get("c"), Some(vec![3.0]));
assert_eq!(cache.entries.len(), 2);
}
#[test]
fn progressive_waveform_waits_for_doubling_but_restarts_after_shrink() {
let waveform = ProgressiveWaveform::new(10, 100, vec![1.0]);
assert!(!waveform.should_recompute(10));
assert!(!waveform.should_recompute(19));
assert!(waveform.should_recompute(20));
assert!(waveform.should_recompute(9));
}
#[test]
fn progressive_thresholds_bound_lag_and_total_decode_cost() {
let total: u64 = 256 * 1024 * 1024;
let mut decoded: u64 = 1024 * 1024;
let mut cumulative = 0u64;
let mut steps = 0;
while decoded < total {
cumulative += decoded;
let next = next_progressive_waveform_threshold(decoded, total);
assert!(next > decoded, "thresholds must advance");
// The drawn wave may never fall more than an eighth of the file
// behind the transfer — the doubling-only schedule's half-file
// final gap is the bug this replaced (playhead walking off the
// stale wave's end mid-song).
assert!(
next - decoded <= total / 8,
"gap {} exceeds total/8",
next - decoded
);
decoded = next;
steps += 1;
}
assert_eq!(decoded, total);
// Early doubling + eighth-of-file steps: the whole progressive series
// costs a small constant number of full-file decodes, spread across
// the download.
assert!(steps <= 16, "too many recomputes: {steps}");
assert!(cumulative <= total * 4, "cumulative redecode too large");
}
}