1use 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
13pub 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 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
93pub 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 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
138fn 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
153static CACHE_BYTES: AtomicU64 = AtomicU64::new(0);
157
158pub fn cache_bytes() -> u64 {
159 CACHE_BYTES.load(Ordering::Relaxed)
160}
161
162pub 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
188pub 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#[derive(Debug, Clone, Copy, Default)]
204pub struct CacheCleared {
205 pub files: u64,
206 pub bytes: u64,
207}
208
209pub 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
233pub 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 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
267pub 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
306pub 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
364pub 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
417pub 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
435pub 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}