Skip to main content

koan_core/helpers/
cache.rs

1//! The download cache: its size, eviction, clearing, and where a track's download lives.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use crate::config::Config;
7use crate::db::connection::{Database, DbError};
8use crate::db::queries;
9use crate::player::state::SharedPlayerState;
10
11use super::*;
12
13/// Remove downloads until the cache is under the configured limit, a file at
14/// a time: those fetched to play first, then those asked for, least recently
15/// used first within each. Never a file whose album has a favourite in it,
16/// nor a track in `keep` — the playback window, whose files the player may be
17/// reading and which are what it wants next (`playback_window`). Returns the
18/// bytes freed.
19pub fn evict_cache(
20    db: &Database,
21    cfg: &Config,
22    keep: &std::collections::HashSet<i64>,
23    verbose: bool,
24) -> u64 {
25    let Some(limit) = cfg.cache_limit_bytes().map(|l| l as i64) else {
26        return 0;
27    };
28    let mut current = match queries::total_cache_size(&db.conn) {
29        Ok(s) => s,
30        Err(e) => {
31            log::warn!("cache eviction: failed to query cache size: {e}");
32            return 0;
33        }
34    };
35    if current <= limit {
36        if verbose {
37            log::info!("cache within limit: {current} / {limit} bytes");
38        }
39        return 0;
40    }
41    let files = match queries::cached_files_lru(&db.conn) {
42        Ok(f) => f,
43        Err(e) => {
44            log::warn!("cache eviction: failed to query cached files: {e}");
45            return 0;
46        }
47    };
48    // A file two tracks record is kept if either is, and removed once.
49    let kept: std::collections::HashSet<&str> = files
50        .iter()
51        .filter(|f| keep.contains(&f.track_id))
52        .map(|f| f.path.as_str())
53        .collect();
54    let mut removed = std::collections::HashSet::new();
55    let mut gone = Vec::new();
56    let mut freed: i64 = 0;
57    for file in files.iter().filter(|f| !kept.contains(f.path.as_str())) {
58        if current <= limit {
59            break;
60        }
61        if !removed.insert(file.path.as_str()) {
62            continue;
63        }
64        match std::fs::remove_file(&file.path) {
65            Ok(()) => {}
66            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
67            Err(e) => {
68                log::warn!("cache eviction: failed to delete {}: {e}", file.path);
69                continue;
70            }
71        }
72        log::info!(
73            "evicted: {} ({} bytes{})",
74            file.path,
75            file.size,
76            if file.pinned { ", pinned" } else { "" }
77        );
78        gone.push(file.track_id);
79        current -= file.size;
80        freed += file.size;
81    }
82    if let Err(e) = queries::clear_cached_paths_for(&db.conn, &gone) {
83        log::warn!("cache eviction: failed to clear DB: {e}");
84    }
85    remove_empty_dirs(&cfg.cache_dir());
86    if freed > 0 {
87        cache_shrank(freed as u64);
88        log::info!("cache eviction freed {freed} bytes");
89    }
90    freed as u64
91}
92
93/// How many of `upcoming` — the playing track, then the rest of the queue in
94/// the order the player reaches it — the cache has room for. The playing
95/// track and the next always: the decoder reads ahead into the next for
96/// gapless. After them each track while the cache stays under `limit`,
97/// counting favourites and pinned downloads first, since the queue does not
98/// displace them. What is already downloaded costs its file, and what is not
99/// its estimated size.
100pub fn playback_window(
101    db: &Database,
102    limit: u64,
103    upcoming: &[i64],
104) -> Result<usize, crate::db::connection::DbError> {
105    let total = queries::total_cache_size(&db.conn)?;
106    let files: Vec<_> = queries::cached_files_lru(&db.conn)?
107        .into_iter()
108        .filter(|f| !f.pinned)
109        .collect();
110    // By file, not by track: two tracks recording one file cost it once.
111    let evictable: std::collections::HashMap<&str, i64> =
112        files.iter().map(|f| (f.path.as_str(), f.size)).collect();
113    let file_of: std::collections::HashMap<i64, &str> = files
114        .iter()
115        .map(|f| (f.track_id, f.path.as_str()))
116        .collect();
117    let estimates = queries::download_estimates(&db.conn, upcoming)?;
118
119    let mut used = (total - evictable.values().sum::<i64>()).max(0) as u64;
120    let mut counted = std::collections::HashSet::new();
121    let mut counted_files = std::collections::HashSet::new();
122    for (n, id) in upcoming.iter().enumerate() {
123        if counted.insert(*id) {
124            let cost = match file_of.get(id) {
125                Some(path) if !counted_files.insert(*path) => None,
126                Some(path) => evictable.get(path),
127                None => estimates.get(id),
128            };
129            used += cost.copied().unwrap_or(0).max(0) as u64;
130        }
131        if n >= 2 && used > limit {
132            return Ok(n);
133        }
134    }
135    Ok(upcoming.len())
136}
137
138/// Remove empty directories under `dir`, leaving `dir` itself.
139fn remove_empty_dirs(dir: &Path) {
140    if !dir.is_dir() {
141        return;
142    }
143    for entry in walkdir::WalkDir::new(dir)
144        .contents_first(true)
145        .into_iter()
146        .filter_map(Result::ok)
147        .filter(|e| e.file_type().is_dir() && e.path() != dir)
148    {
149        let _ = std::fs::remove_dir(entry.path());
150    }
151}
152
153/// Bytes in the download cache, as front ends show it: measured whole by
154/// `measure_cache` at startup and on a clear, and kept between them by what
155/// lands and leaves, so a reading never walks the disk.
156static CACHE_BYTES: AtomicU64 = AtomicU64::new(0);
157
158pub fn cache_bytes() -> u64 {
159    CACHE_BYTES.load(Ordering::Relaxed)
160}
161
162/// Walk the cache and take what it holds as the count.
163pub fn measure_cache(cfg: &Config) -> u64 {
164    let bytes = cache_size_bytes(cfg);
165    CACHE_BYTES.store(bytes, Ordering::Relaxed);
166    crate::signal::engine_changed().bump();
167    bytes
168}
169
170pub(super) fn cache_grew(bytes: u64) {
171    CACHE_BYTES.fetch_add(bytes, Ordering::Relaxed);
172    crate::signal::engine_changed().bump();
173}
174
175pub(crate) fn cache_shrank(bytes: u64) {
176    let mut now = CACHE_BYTES.load(Ordering::Relaxed);
177    while let Err(moved) = CACHE_BYTES.compare_exchange_weak(
178        now,
179        now.saturating_sub(bytes),
180        Ordering::Relaxed,
181        Ordering::Relaxed,
182    ) {
183        now = moved;
184    }
185    crate::signal::engine_changed().bump();
186}
187
188/// Bytes on disk in the download cache, walked. A `.part` is left out: it is
189/// counted once it lands, and a walk racing the download would count it
190/// twice.
191pub fn cache_size_bytes(cfg: &Config) -> u64 {
192    walkdir::WalkDir::new(cfg.cache_dir())
193        .into_iter()
194        .filter_map(Result::ok)
195        .filter(|e| e.file_type().is_file())
196        .filter(|e| e.path().extension().is_none_or(|ext| ext != "part"))
197        .filter_map(|e| e.metadata().ok())
198        .map(|m| m.len())
199        .sum()
200}
201
202/// What clearing the download cache removed.
203#[derive(Debug, Clone, Copy, Default)]
204pub struct CacheCleared {
205    pub files: u64,
206    pub bytes: u64,
207}
208
209/// Delete every downloaded remote track and forget where they were.
210///
211/// The rows stay — a remote track is still in the library, it just has to be
212/// fetched again to play.
213pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
214    let dir = cfg.cache_dir();
215    let mut cleared = CacheCleared::default();
216    for entry in walkdir::WalkDir::new(&dir)
217        .into_iter()
218        .filter_map(Result::ok)
219        .filter(|e| e.file_type().is_file())
220    {
221        if let Ok(meta) = entry.metadata() {
222            cleared.bytes += meta.len();
223            cleared.files += 1;
224        }
225    }
226    let _ = std::fs::remove_dir_all(&dir);
227    let _ = std::fs::create_dir_all(&dir);
228    let _ = queries::clear_cached_paths(&db.conn);
229    measure_cache(cfg);
230    cleared
231}
232
233/// Delete the downloaded copies of just these tracks.
234///
235/// The per-track counterpart of `clear_download_cache`, for throwing away one
236/// record rather than the lot. A track playing from a copy being removed keeps
237/// playing — the decoder holds the file open, and unlinking it only takes the
238/// name away — but the next play fetches it again.
239pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
240    let mut cleared = CacheCleared::default();
241    let paths = match queries::cached_paths_for(&db.conn, track_ids) {
242        Ok(paths) => paths,
243        Err(e) => {
244            log::warn!("could not read cached paths: {e}");
245            return cleared;
246        }
247    };
248    for path in &paths {
249        let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
250        match std::fs::remove_file(path) {
251            Ok(()) => {
252                cleared.files += 1;
253                cleared.bytes += size;
254            }
255            // Already gone is the outcome asked for, so it is not a failure.
256            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
257            Err(e) => log::warn!("could not remove {path}: {e}"),
258        }
259    }
260    if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
261        log::warn!("removed downloads but failed to forget them ({e})");
262    }
263    cache_shrank(cleared.bytes);
264    cleared
265}
266
267/// Throw away half-finished downloads left behind by a previous run.
268///
269/// A `.part` file only means something to the transfer writing it. koan does
270/// not resume — the file is written straight through and renamed at the end —
271/// so one still on disk at startup is from a run that did not finish, and it
272/// will be truncated and rewritten the next time that track is wanted anyway.
273/// Until then it is bytes nothing knows about: cache eviction only tracks what
274/// finished, so an interrupted download of a nine-hour recording is half a
275/// gigabyte that never gets reclaimed.
276///
277/// At startup rather than at exit, because a run that ends without getting to
278/// its own cleanup is exactly the run that leaves these behind.
279pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
280    let mut swept = CacheCleared::default();
281    for entry in walkdir::WalkDir::new(cfg.cache_dir())
282        .into_iter()
283        .filter_map(Result::ok)
284        .filter(|e| e.file_type().is_file())
285        .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
286    {
287        let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
288        match std::fs::remove_file(entry.path()) {
289            Ok(()) => {
290                swept.files += 1;
291                swept.bytes += size;
292            }
293            Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
294        }
295    }
296    if swept.files > 0 {
297        log::info!(
298            "swept {} unfinished download(s), {} bytes",
299            swept.files,
300            swept.bytes
301        );
302    }
303    swept
304}
305
306/// Re-root cached paths that name a cache directory other than `cache_dir`.
307///
308/// iOS gives an app a new container path when it is updated. The cache moves
309/// with it, but the absolute paths stored for its files do not, so every
310/// download looks missing: tracks are fetched again beside the copies already
311/// there, and eviction cannot find the old ones to delete. The cache lays
312/// files out as `<cache>/<artist>/<album>/<file>` (`cache_path_for_track`), so
313/// a stale path is re-rooted on its last three components when that file
314/// exists under `cache_dir`. Paths whose file is not there are left alone; the
315/// next play resolves them as it would any missing download.
316///
317/// Returns the number of paths rewritten.
318pub fn relocate_cached_paths(db: &Database, cache_dir: &Path) -> rusqlite::Result<usize> {
319    let prefix = format!("{}/", cache_dir.to_string_lossy().trim_end_matches('/'));
320    let stale: Vec<(i64, String)> = db
321        .conn
322        .prepare(
323            "SELECT id, cached_path FROM tracks
324             WHERE cached_path IS NOT NULL AND substr(cached_path, 1, ?2) != ?1",
325        )?
326        .query_map(
327            rusqlite::params![prefix, prefix.chars().count() as i64],
328            |r| Ok((r.get(0)?, r.get(1)?)),
329        )?
330        .collect::<rusqlite::Result<_>>()?;
331    if stale.is_empty() {
332        return Ok(0);
333    }
334
335    let tx = crate::db::queries::write_transaction(&db.conn)?;
336    let mut moved = 0;
337    for (id, old) in &stale {
338        let tail: Vec<_> = Path::new(old).components().rev().take(3).collect();
339        if tail.len() < 3 {
340            continue;
341        }
342        let new = tail
343            .iter()
344            .rev()
345            .fold(cache_dir.to_path_buf(), |p, c| p.join(c));
346        if new.is_file() {
347            tx.execute(
348                "UPDATE tracks SET cached_path = ?1 WHERE id = ?2",
349                rusqlite::params![new.to_string_lossy(), id],
350            )?;
351            moved += 1;
352        }
353    }
354    tx.commit()?;
355    if moved > 0 {
356        log::info!(
357            "re-rooted {moved} cached path(s) under {}",
358            cache_dir.display()
359        );
360    }
361    Ok(moved)
362}
363
364/// Record the downloads the cache holds that no track names.
365///
366/// A file can outlive its record: a track re-derived or merged without it, an
367/// older build, a crash between the rename and the write. Unrecorded, it reads
368/// as not on this machine and eviction cannot see it. Each is matched to the
369/// remote track whose download would land at that path
370/// (`cache_path_for_track`), the same match a play makes. A file no track
371/// lays out to is left alone: nothing would ever play it, and nothing here
372/// knows enough to say it is safe to delete.
373///
374/// Returns the number of tracks recorded.
375pub fn adopt_cached_files(db: &Database, cache_dir: &Path) -> Result<usize, DbError> {
376    let recorded: std::collections::HashSet<String> = db
377        .conn
378        .prepare("SELECT DISTINCT cached_path FROM tracks WHERE cached_path IS NOT NULL")?
379        .query_map([], |r| r.get(0))?
380        .collect::<rusqlite::Result<_>>()?;
381    let orphans: std::collections::HashSet<PathBuf> = walkdir::WalkDir::new(cache_dir)
382        .into_iter()
383        .filter_map(Result::ok)
384        .filter(|e| e.file_type().is_file())
385        .filter(|e| e.path().extension().is_some_and(|ext| ext != "part"))
386        .map(walkdir::DirEntry::into_path)
387        .filter(|p| !recorded.contains(&*p.to_string_lossy()))
388        .collect();
389    if orphans.is_empty() {
390        return Ok(0);
391    }
392
393    let ids: Vec<i64> = db
394        .conn
395        .prepare("SELECT id FROM tracks WHERE cached_path IS NULL AND remote_id IS NOT NULL")?
396        .query_map([], |r| r.get(0))?
397        .collect::<rusqlite::Result<_>>()?;
398    let extras = queries::queue_item_extras(&db.conn, &ids)?;
399    let tracks = queries::tracks_by_ids(&db.conn, &ids)?;
400    let tx = queries::write_transaction(&db.conn)?;
401    let mut adopted = 0;
402    for track in &tracks {
403        let date = extras.get(&track.id).and_then(|e| e.album_date.as_deref());
404        let dest = cache_path_for_track(cache_dir, track, date);
405        if orphans.contains(&dest) && is_cached_audio(&dest) {
406            queries::set_cached_path(&tx, track.id, &dest.to_string_lossy())?;
407            adopted += 1;
408        }
409    }
410    tx.commit()?;
411    if adopted > 0 {
412        log::info!("recorded {adopted} download(s) the cache held unrecorded");
413    }
414    Ok(adopted)
415}
416
417/// Fetch again anything in the queue whose downloaded copy has just been
418/// removed.
419///
420/// Clearing downloads deletes files the queue is still pointing at, and an item
421/// that goes on claiming to be ready plays nothing at all. Call this after
422/// either clearing function, from anywhere with a player attached. Putting the
423/// items back to `Pending` is all it takes: the download queue follows the
424/// playlist and fetches them.
425pub fn requeue_cleared_downloads(state: &SharedPlayerState) {
426    let stale = state.reset_items_with_missing_files();
427    if !stale.is_empty() {
428        log::info!(
429            "{} queued tracks lost their copy — fetching again",
430            stale.len()
431        );
432    }
433}
434
435/// Build a structured cache path for a track:
436///   cache_dir/Album Artist/(Year) Album [Codec]/01. Track Artist - Title.ext
437pub fn cache_path_for_track(
438    cache_dir: &Path,
439    track: &queries::TrackRow,
440    album_date: Option<&str>,
441) -> PathBuf {
442    let artist_dir = sanitise_filename(&track.artist_name);
443
444    let year = album_date
445        .and_then(year_of)
446        .map(|y| format!("({}) ", y))
447        .unwrap_or_default();
448    let codec = track
449        .codec
450        .as_deref()
451        .map(|c| format!(" [{}]", c))
452        .unwrap_or_default();
453    let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
454
455    let disc_prefix = match track.disc {
456        Some(d) if d > 1 => format!("{}-", d),
457        _ => String::new(),
458    };
459    let track_num = track
460        .track_number
461        .map(|n| format!("{:02}. ", n))
462        .unwrap_or_default();
463
464    let ext = track
465        .codec
466        .as_deref()
467        .and_then(sanitise_extension)
468        .unwrap_or_else(|| "flac".into());
469
470    let filename = sanitise_filename(&format!(
471        "{}{}{} - {}",
472        disc_prefix, track_num, track.artist_name, track.title
473    ));
474
475    cache_dir
476        .join(artist_dir)
477        .join(album_dir)
478        .join(format!("{}.{}", filename, ext))
479}
480
481#[cfg(test)]
482mod cache_path_tests {
483    use super::*;
484
485    fn track(artist: &str, album: &str, codec: &str) -> queries::TrackRow {
486        queries::TrackRow {
487            id: 1,
488            album_id: None,
489            artist_id: None,
490            artist_name: artist.into(),
491            album_artist_name: artist.into(),
492            album_title: album.into(),
493            disc: None,
494            track_number: Some(1),
495            title: "Song".into(),
496            duration_ms: None,
497            path: None,
498            codec: Some(codec.into()),
499            sample_rate: None,
500            bit_depth: None,
501            channels: None,
502            bitrate: None,
503            genre: None,
504            source: "remote".into(),
505            remote_id: Some("r1".into()),
506            cached_path: None,
507        }
508    }
509
510    #[test]
511    fn a_server_suffix_cannot_leave_the_cache() {
512        let cache = Path::new("/cache");
513        for codec in [
514            "flac/../../../../x",
515            "..",
516            "../..",
517            "/etc/passwd",
518            "\\..\\..",
519        ] {
520            let path = cache_path_for_track(cache, &track("A", "B", codec), None);
521            assert!(path_within(cache, &path), "{codec}: {}", path.display());
522        }
523        let path = cache_path_for_track(cache, &track("A", "B", "flac/../../../../x"), None);
524        assert_eq!(path.extension().unwrap(), "flacx");
525    }
526
527    #[test]
528    fn dot_names_cannot_climb_out() {
529        let cache = Path::new("/cache");
530        let path = cache_path_for_track(cache, &track("..", ".", ".."), None);
531        assert!(path_within(cache, &path), "{}", path.display());
532        assert_eq!(sanitise_filename(".."), "_");
533        assert_eq!(sanitise_filename(" . "), "_");
534        assert_eq!(sanitise_filename("..."), "...");
535    }
536
537    #[test]
538    fn an_empty_suffix_falls_back_to_flac() {
539        assert_eq!(sanitise_extension("../"), None);
540        assert_eq!(sanitise_extension("FLAC"), Some("flac".into()));
541        let path = cache_path_for_track(Path::new("/c"), &track("A", "B", "./"), None);
542        assert_eq!(path.extension().unwrap(), "flac");
543    }
544
545    #[test]
546    fn path_within_rejects_parent_components() {
547        let dir = Path::new("/cache");
548        assert!(path_within(dir, Path::new("/cache/a/b.flac")));
549        assert!(!path_within(dir, Path::new("/cache/a/../../x")));
550        assert!(!path_within(dir, Path::new("/elsewhere/x")));
551    }
552}