Skip to main content

koan_server/
covers.rs

1//! Album covers for the web UI, share pages and Subsonic: an image beside the
2//! tracks or the art embedded in them, resized to one of a few sizes, encoded
3//! as JPEG, and kept.
4//!
5//! Reading embedded art means opening the audio file and parsing its tags, and
6//! the art itself is often megabytes; doing that per tile made an albums grid
7//! pull tens of megabytes and seconds of server time. Resized covers are kept
8//! on disk under the config directory, keyed by the source file's path, size
9//! and mtime so a re-tag or a replaced image is a new key. An album with no
10//! art is remembered too, so a grid of them does not reopen every file on
11//! every visit. The directory is held to `BUDGET`: every so many writes the
12//! covers read least recently are removed, and so are the stale keys a re-tag
13//! leaves and temporary files an interrupted write left behind.
14//!
15//! Nothing is held in memory: the kernel's page cache keeps hot files for
16//! free and gives the memory back under pressure, where a cache of our own
17//! would count against the process for as long as it runs. Decoding is the
18//! expensive part — a full-size cover is tens of megabytes of pixels — so it
19//! runs on a few threads of its own. A grid opening asks for hundreds of covers
20//! at once; on the blocking pool each request would decode on its own thread,
21//! and the allocator keeps what every one of those threads used.
22//!
23//! What the cache holds, what the warm is doing and what the last sweep
24//! removed are reported (`usage`, `report`) for the web UI's Library page and
25//! GraphQL's `serverCaches`, with `subscribe` ringing as they move.
26
27use std::path::{Path, PathBuf};
28use std::sync::LazyLock;
29
30use axum::body::Bytes;
31
32use koan_core::db::queries::TrackRow;
33
34/// The sizes a cover is served at, the ones every koan client asks for
35/// (`koan_core::remote::client::COVER_SIZES`). A request is rounded up to
36/// one of these, which bounds what the cache can hold per album.
37pub(crate) const SIZES: [u32; 4] = koan_core::remote::client::COVER_SIZES;
38/// Grid tiles, at 2x: the web UI's and the desktop app's.
39pub(crate) const GRID: u32 = 600;
40/// Headers, the player, and link previews.
41pub(crate) const LARGE: u32 = 1200;
42/// What the warm makes for every album: the desktop's and the web UI's grid,
43/// and the phones' thumbnails. The Apple apps' 1200 is made as asked: a
44/// library's worth of them would outgrow `BUDGET`.
45pub(crate) const WARMED: [u32; 2] = [300, GRID];
46
47/// How many of an album's tracks are opened looking for art before giving up.
48const TRACKS_TRIED: usize = 3;
49const JPEG_QUALITY: u8 = 85;
50
51pub(crate) fn snap(size: Option<u32>) -> u32 {
52    let size = size.unwrap_or(LARGE);
53    SIZES
54        .into_iter()
55        .find(|s| *s >= size)
56        .unwrap_or(SIZES[SIZES.len() - 1])
57}
58
59/// The threads covers are decoded and resized on.
60static DECODE: LazyLock<rayon::ThreadPool> = LazyLock::new(|| {
61    let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(1, 4));
62    rayon::ThreadPoolBuilder::new()
63        .num_threads(threads)
64        .thread_name(|i| format!("koan-covers-{i}"))
65        .build()
66        .expect("cover threads")
67});
68
69/// What the cover directory may hold before the least recently read go.
70pub(crate) const BUDGET: u64 = 4 * 1024 * 1024 * 1024;
71/// Writes between looks at the directory's size.
72const SWEEP_EVERY: u64 = 256;
73/// A hit moves a cover's mtime, which is its place in line for eviction, no
74/// more than once in this long.
75const TOUCH_AFTER: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
76/// Where the newest eviction applied is kept, beside the covers. A dot file,
77/// which the sweep leaves alone.
78const EVICTED: &str = ".evicted";
79/// How often the warm looks for albums without a cover ready, besides when
80/// the library changes: albums added by a scan evict nothing.
81pub(crate) const WARM_EVERY: std::time::Duration = std::time::Duration::from_secs(10 * 60);
82/// A temporary file older than this was left by a write that never finished.
83const TMP_LEFT: std::time::Duration = std::time::Duration::from_secs(60 * 60);
84
85pub struct Covers {
86    dir: PathBuf,
87    budget: u64,
88    writes: std::sync::atomic::AtomicU64,
89    sweeping: std::sync::Arc<std::sync::atomic::AtomicBool>,
90    /// The newest `art_evictions` entry already applied to the album
91    /// entries; `None` until the first look, which only reads where the
92    /// library has got to.
93    evicted: parking_lot::Mutex<Option<i64>>,
94    /// Wakes the warm when the library has changed.
95    warm: (parking_lot::Mutex<bool>, parking_lot::Condvar),
96    report: std::sync::Arc<parking_lot::Mutex<Report>>,
97    /// Moves whenever the report does, or covers are cleared.
98    changed: std::sync::Arc<tokio::sync::watch::Sender<u64>>,
99}
100
101/// What the warm is doing.
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub(crate) enum WarmState {
104    /// Not started: an app sharing its own library makes covers as asked.
105    Off,
106    Running,
107    /// Stopped with the directory at its budget; requests still write.
108    AtBudget,
109    Idle,
110}
111
112/// The warm's pass in progress, or its last.
113#[derive(Clone, Copy, Debug)]
114pub(crate) struct Warm {
115    pub state: WarmState,
116    /// Records looked at so far, of how many there are with files.
117    pub done: usize,
118    pub total: usize,
119    /// Records the pass has made covers for, the rest being ready already.
120    pub made: usize,
121    pub started: Option<std::time::SystemTime>,
122    pub finished: Option<std::time::SystemTime>,
123}
124
125impl Warm {
126    /// When the next pass runs unless the library changes first.
127    pub(crate) fn next(&self) -> Option<std::time::SystemTime> {
128        match self.state {
129            WarmState::Idle | WarmState::AtBudget => self.finished.map(|t| t + WARM_EVERY),
130            WarmState::Off | WarmState::Running => None,
131        }
132    }
133}
134
135/// What a sweep held the directory to its budget by removing.
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub(crate) struct Sweep {
138    pub at: std::time::SystemTime,
139    pub removed: usize,
140    pub bytes: u64,
141}
142
143#[derive(Clone, Copy, Debug)]
144pub(crate) struct Report {
145    pub warm: Warm,
146    /// The last sweep that found the directory over its budget, since start.
147    pub sweep: Option<Sweep>,
148}
149
150/// The covers kept at one size.
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub(crate) struct SizeUsage {
153    pub size: u32,
154    /// Covers kept, remembered misses included.
155    pub covers: usize,
156    pub bytes: u64,
157    /// Records or files remembered to have no art.
158    pub misses: usize,
159}
160
161/// What the cover directory holds.
162#[derive(Clone, Debug, Default, PartialEq, Eq)]
163pub(crate) struct Usage {
164    pub bytes: u64,
165    pub budget: u64,
166    pub sizes: Vec<SizeUsage>,
167    /// Records with files that lack a cover ready at one of `WARMED`.
168    pub unwarmed: usize,
169}
170
171impl Usage {
172    pub(crate) fn covers(&self) -> usize {
173        self.sizes.iter().map(|s| s.covers).sum()
174    }
175
176    pub(crate) fn misses(&self) -> usize {
177        self.sizes.iter().map(|s| s.misses).sum()
178    }
179}
180
181impl Covers {
182    pub fn new(dir: PathBuf) -> Self {
183        let evicted = std::fs::read_to_string(dir.join(EVICTED))
184            .ok()
185            .and_then(|s| s.trim().parse().ok());
186        Self {
187            dir,
188            budget: BUDGET,
189            writes: Default::default(),
190            sweeping: Default::default(),
191            evicted: parking_lot::Mutex::new(evicted),
192            warm: Default::default(),
193            report: std::sync::Arc::new(parking_lot::Mutex::new(Report {
194                warm: Warm {
195                    state: WarmState::Off,
196                    done: 0,
197                    total: 0,
198                    made: 0,
199                    started: None,
200                    finished: None,
201                },
202                sweep: None,
203            })),
204            changed: std::sync::Arc::new(tokio::sync::watch::Sender::new(0)),
205        }
206    }
207
208    pub(crate) fn report(&self) -> Report {
209        *self.report.lock()
210    }
211
212    /// Wakes whenever the report moves or the covers are cleared.
213    pub(crate) fn subscribe(&self) -> tokio::sync::watch::Receiver<u64> {
214        self.changed.subscribe()
215    }
216
217    fn ring(&self) {
218        ring(&self.changed);
219    }
220
221    fn set_warm(&self, change: impl FnOnce(&mut Warm)) {
222        change(&mut self.report.lock().warm);
223        self.ring();
224    }
225
226    /// What the directory holds, by size, and how many of the library's
227    /// records lack a warmed cover. Lists the directory: blocking.
228    pub(crate) fn usage(&self, conn: &rusqlite::Connection) -> Usage {
229        let mut sizes: Vec<SizeUsage> = SIZES
230            .into_iter()
231            .map(|size| SizeUsage {
232                size,
233                ..Default::default()
234            })
235            .collect();
236        let mut bytes = 0;
237        // Records with an entry at each warmed size, by bit.
238        let mut warmed: std::collections::HashMap<i64, u8> = Default::default();
239        if let Ok(read) = std::fs::read_dir(&self.dir) {
240            for entry in read.flatten() {
241                let len = entry.metadata().map_or(0, |m| m.len());
242                bytes += len;
243                let name = entry.file_name().to_string_lossy().into_owned();
244                let Some((album, size)) = parse(&name) else {
245                    continue;
246                };
247                if let Some(at) = sizes.iter_mut().find(|s| s.size == size) {
248                    at.covers += 1;
249                    at.bytes += len;
250                    at.misses += usize::from(len == 0);
251                }
252                if let (Some(album), Some(bit)) = (album, WARMED.iter().position(|s| *s == size)) {
253                    *warmed.entry(album).or_default() |= 1 << bit;
254                }
255            }
256        }
257        let all = (1u8 << WARMED.len()) - 1;
258        let unwarmed = conn
259            .prepare(
260                "SELECT DISTINCT album_id FROM tracks
261                  WHERE path IS NOT NULL AND album_id IS NOT NULL",
262            )
263            .and_then(|mut stmt| {
264                stmt.query_map([], |r| r.get::<_, i64>(0)).map(|ids| {
265                    ids.flatten()
266                        .filter(|id| warmed.get(id) != Some(&all))
267                        .count()
268                })
269            })
270            .unwrap_or(0);
271        Usage {
272            bytes,
273            budget: self.budget,
274            sizes,
275            unwarmed,
276        }
277    }
278
279    /// Removes every cover kept, misses included, and wakes the warm to make
280    /// the grid sizes again. What was removed: files and bytes. Blocking.
281    pub(crate) fn clear(&self) -> (usize, u64) {
282        let (mut files, mut bytes) = (0, 0);
283        if let Ok(read) = std::fs::read_dir(&self.dir) {
284            for entry in read.flatten() {
285                if entry.file_name().to_string_lossy().starts_with('.') {
286                    continue;
287                }
288                let len = entry.metadata().map_or(0, |m| m.len());
289                if std::fs::remove_file(entry.path()).is_ok() {
290                    files += 1;
291                    bytes += len;
292                }
293            }
294        }
295        log::info!("covers: cleared {files} covers ({} MB)", bytes >> 20);
296        self.ring();
297        self.poke();
298        (files, bytes)
299    }
300
301    /// Starts a warm pass now rather than at the next change or interval.
302    /// Whether a warm runs to start.
303    pub(crate) fn warm_now(&self) -> bool {
304        if self.report().warm.state == WarmState::Off {
305            return false;
306        }
307        self.poke();
308        true
309    }
310
311    /// An album's cover at `size` from the album's own entry: one small file
312    /// read, no music file opened, listed or even looked at. Without an entry,
313    /// `make` finds it (the tracks' art, as `cover` does) and its answer is
314    /// kept for the album, no art included; `None` from `make` says it could
315    /// not tell, and nothing is kept. Blocking.
316    pub(crate) fn album_cover(
317        &self,
318        album: i64,
319        size: u32,
320        make: impl FnOnce() -> Option<Option<Bytes>>,
321    ) -> Option<Bytes> {
322        let key = album_key(album, size);
323        if let Some(hit) = self.read_disk(&key) {
324            return hit;
325        }
326        let art = make()?;
327        self.write_disk(&key, art.as_deref());
328        art
329    }
330
331    /// An album's cover at `size` if its own entry is kept: the cover, a
332    /// kept answer that it has none, or `None` when nothing is kept and only
333    /// reading its files would tell. Reads one small file at most.
334    pub(crate) fn album_cover_kept(&self, album: i64, size: u32) -> Option<Option<Bytes>> {
335        self.read_disk(&album_key(album, size))
336    }
337
338    /// Drops the entries of albums the library has named in `art_evictions`
339    /// since the last look (deleted, or rescanned with perhaps another image
340    /// beside them), so their next request reads the files again. A mark the
341    /// table has outlived (entries pruned past it, or a database made afresh)
342    /// drops every album's entry, since their ids may now be other records'.
343    /// A few indexed reads when nothing has changed.
344    pub(crate) fn forget_evicted(&self, conn: &rusqlite::Connection) {
345        let mut seen = self.evicted.lock();
346        let Some(since) = *seen else {
347            // No entries kept yet: nothing to forget.
348            if let Ok(now) = koan_core::db::queries::art_evictions_since(conn, i64::MAX) {
349                *seen = Some(now.seq);
350                self.mark(now.seq);
351            }
352            return;
353        };
354        let Ok(gone) = koan_core::db::queries::art_evictions_since(conn, since) else {
355            return;
356        };
357        if gone.seq == since && !gone.purge {
358            return;
359        }
360        if gone.purge {
361            if let Ok(read) = std::fs::read_dir(&self.dir) {
362                for entry in read.flatten() {
363                    let name = entry.file_name();
364                    if matches!(parse(&name.to_string_lossy()), Some((Some(_), _))) {
365                        let _ = std::fs::remove_file(entry.path());
366                    }
367                }
368            }
369            log::info!("covers: the library's evictions outran the cache; album covers dropped");
370        } else {
371            for (kind, id) in &gone.named {
372                if kind == "album" {
373                    for size in SIZES {
374                        let _ = std::fs::remove_file(self.dir.join(album_key(*id, size)));
375                    }
376                }
377            }
378        }
379        *seen = Some(gone.seq);
380        self.mark(gone.seq);
381        drop(seen);
382        self.poke();
383    }
384
385    fn mark(&self, seq: i64) {
386        let _ = std::fs::create_dir_all(&self.dir);
387        let _ = std::fs::write(self.dir.join(EVICTED), seq.to_string());
388    }
389
390    /// Wakes the warm for another pass.
391    fn poke(&self) {
392        *self.warm.0.lock() = true;
393        self.warm.1.notify_one();
394    }
395
396    /// Keeps every album's grid-size cover ready, on a thread of its own:
397    /// at start, after the library changes, and every so often, one album at
398    /// a time in the order its files lie in the library, so the disk reads
399    /// in order rather than seeking between requests' files.
400    pub fn warm(self: &std::sync::Arc<Self>, pool: std::sync::Arc<koan_core::db::pool::Pool>) {
401        let covers = self.clone();
402        let started = std::thread::Builder::new()
403            .name("koan-covers-warm".into())
404            .spawn(move || {
405                prune(&covers.dir);
406                covers.set_warm(|w| w.state = WarmState::Idle);
407                loop {
408                    covers.warm_pass(&pool);
409                    let mut poked = covers.warm.0.lock();
410                    if !*poked {
411                        covers.warm.1.wait_for(&mut poked, WARM_EVERY);
412                    }
413                    *poked = false;
414                }
415            });
416        if let Err(e) = started {
417            log::warn!("covers: no warm thread: {e}");
418        }
419    }
420
421    fn warm_pass(&self, pool: &koan_core::db::pool::Pool) {
422        // Warmed covers are the least recently read, so past the budget the
423        // sweep would take them first and the next pass would make them
424        // again: the warm stops at the budget, and requests still write.
425        let mut used = dir_bytes(&self.dir);
426        if used >= self.budget {
427            self.set_warm(|w| {
428                w.state = WarmState::AtBudget;
429                w.finished = Some(std::time::SystemTime::now());
430            });
431            return;
432        }
433        let albums: Vec<i64> = {
434            let Ok(db) = pool.get() else { return };
435            self.forget_evicted(&db.conn);
436            let Ok(mut stmt) = db.conn.prepare(
437                "SELECT album_id FROM tracks
438                  WHERE path IS NOT NULL AND album_id IS NOT NULL
439                  GROUP BY album_id ORDER BY MIN(path)",
440            ) else {
441                return;
442            };
443            stmt.query_map([], |r| r.get(0))
444                .map(|rows| rows.flatten().collect())
445                .unwrap_or_default()
446        };
447        let total = albums.len();
448        self.set_warm(|w| {
449            *w = Warm {
450                state: WarmState::Running,
451                done: 0,
452                total,
453                made: 0,
454                started: Some(std::time::SystemTime::now()),
455                finished: w.finished,
456            }
457        });
458        let mut made = 0usize;
459        let mut state = WarmState::Idle;
460        let mut finished = true;
461        for (done, album) in albums.into_iter().enumerate() {
462            let wanted: Vec<u32> = WARMED
463                .into_iter()
464                .filter(|size| !self.dir.join(album_key(album, *size)).exists())
465                .collect();
466            if wanted.is_empty() {
467                // Ready already: counted without a ring per record, which
468                // would wake every follower thousands of times a pass.
469                self.report.lock().warm.done = done + 1;
470                continue;
471            }
472            let tracks = match pool.get() {
473                Ok(db) => {
474                    koan_core::db::queries::tracks_for_album(&db.conn, album).unwrap_or_default()
475                }
476                Err(_) => {
477                    finished = false;
478                    break;
479                }
480            };
481            // Files that cannot be read (a volume not mounted yet) say
482            // nothing about whether the album has art: nothing is kept, and
483            // the next pass tries again.
484            if !readable(&tracks) {
485                continue;
486            }
487            // The first source that makes a cover, read once for every size
488            // wanted.
489            let mut sources = self.source_arts(&tracks);
490            let mut source: Option<Vec<u8>> = None;
491            for size in wanted {
492                let art = DECODE.install(|| {
493                    if let Some(bytes) = &source {
494                        return encode(bytes, size);
495                    }
496                    sources.find_map(|bytes| {
497                        let art = encode(&bytes, size)?;
498                        source = Some(bytes);
499                        Some(art)
500                    })
501                });
502                used += art.as_ref().map_or(0, |a| a.len() as u64);
503                self.write_disk(&album_key(album, size), art.as_deref());
504            }
505            made += 1;
506            self.set_warm(|w| {
507                w.done = done + 1;
508                w.made = made;
509            });
510            if used >= self.budget {
511                log::info!("covers: the cover directory has reached its budget; warming stops");
512                state = WarmState::AtBudget;
513                break;
514            }
515        }
516        // A pass the database stopped keeps the count it reached.
517        self.set_warm(|w| {
518            w.state = state;
519            if state == WarmState::Idle && finished {
520                w.done = total;
521            }
522            w.finished = Some(std::time::SystemTime::now());
523        });
524        if made > 0 {
525            log::info!("covers: {made} albums' covers made ready");
526        }
527    }
528
529    /// Covers kept under koan's config directory.
530    pub fn in_config_dir() -> Self {
531        Self::new(koan_core::config::config_dir().join("covers"))
532    }
533
534    /// A cover from the first of `tracks` that has art, at `size` (one of
535    /// `SIZES`), as JPEG. An image beside a track comes before the art
536    /// embedded in it (see `koan_core::index::folder_art`). Blocking: call it
537    /// off the async workers.
538    pub(crate) fn cover(&self, tracks: &[TrackRow], size: u32) -> Option<Bytes> {
539        let sources: Vec<(Source, String)> = tracks
540            .iter()
541            .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
542            .take(TRACKS_TRIED)
543            .map(|p| {
544                let source = match koan_core::index::folder_art::folder_cover(&p) {
545                    Some(image) => Source::Image(image),
546                    None => Source::Embedded(p),
547                };
548                let key = key(source.path(), size);
549                (source, key)
550            })
551            .collect();
552        // Keyed on the first candidate: that is the file whose art is shown
553        // whenever it has any. An image added beside it is a new key, so a
554        // remembered miss does not outlive it.
555        let (_, first_key) = sources.first()?;
556        if let Some(hit) = self.read_disk(first_key) {
557            return hit;
558        }
559        // Files that cannot be read now say nothing: no miss is kept.
560        if !readable(tracks) {
561            return None;
562        }
563        let art = DECODE.install(|| {
564            sources.iter().find_map(|(source, _)| {
565                let bytes = match source {
566                    Source::Image(p) => std::fs::read(p).ok(),
567                    Source::Embedded(p) => koan_core::index::metadata::extract_cover_art(p),
568                };
569                bytes.and_then(|bytes| encode(&bytes, size))
570            })
571        });
572        self.write_disk(first_key, art.as_deref());
573        art.map(Bytes::from)
574    }
575
576    /// The art of each of the first of `tracks` that has any, as found, read
577    /// as asked for: an image beside it, or the art embedded in it.
578    /// Unresized.
579    fn source_arts<'a>(&self, tracks: &'a [TrackRow]) -> impl Iterator<Item = Vec<u8>> + 'a {
580        tracks
581            .iter()
582            .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
583            .take(TRACKS_TRIED)
584            .filter_map(|p| match koan_core::index::folder_art::folder_cover(&p) {
585                Some(image) => std::fs::read(image).ok(),
586                None => koan_core::index::metadata::extract_cover_art(&p),
587            })
588    }
589
590    /// `Some(None)` is a remembered miss. A hit is marked as read, so the
591    /// covers in use are the last evicted.
592    fn read_disk(&self, key: &str) -> Option<Option<Bytes>> {
593        use std::io::Read as _;
594        let mut file = std::fs::File::open(self.dir.join(key)).ok()?;
595        let mut bytes = Vec::new();
596        file.read_to_end(&mut bytes).ok()?;
597        let now = std::time::SystemTime::now();
598        if file
599            .metadata()
600            .and_then(|m| m.modified())
601            .is_ok_and(|t| now.duration_since(t).unwrap_or_default() > TOUCH_AFTER)
602        {
603            let _ = file.set_modified(now);
604        }
605        Some((!bytes.is_empty()).then(|| Bytes::from(bytes)))
606    }
607
608    /// An empty file records that there is no art. Failure to write is only a
609    /// lost cache entry. Requests that miss the same cover together each write
610    /// a temporary file of their own, so the one renamed into place last is
611    /// whole rather than another's half-written file.
612    fn write_disk(&self, key: &str, art: Option<&[u8]>) {
613        use std::sync::atomic::Ordering;
614        let _ = std::fs::create_dir_all(&self.dir);
615        let n = self.writes.fetch_add(1, Ordering::Relaxed);
616        if n.is_multiple_of(SWEEP_EVERY) && !self.sweeping.swap(true, Ordering::AcqRel) {
617            let (dir, budget, sweeping) = (self.dir.clone(), self.budget, self.sweeping.clone());
618            let (report, changed) = (self.report.clone(), self.changed.clone());
619            std::thread::spawn(move || {
620                if let Some((removed, bytes)) = sweep(&dir, budget) {
621                    report.lock().sweep = Some(Sweep {
622                        at: std::time::SystemTime::now(),
623                        removed,
624                        bytes,
625                    });
626                    ring(&changed);
627                }
628                sweeping.store(false, Ordering::Release);
629            });
630        }
631        let tmp = self
632            .dir
633            .join(format!("{key}.{}-{n}.tmp", std::process::id()));
634        if std::fs::write(&tmp, art.unwrap_or_default()).is_ok()
635            && std::fs::rename(&tmp, self.dir.join(key)).is_err()
636        {
637            let _ = std::fs::remove_file(&tmp);
638        }
639    }
640}
641
642/// Removes what no request will read again: covers kept at sizes no longer
643/// served, and files in no scheme of this version's. They count against the
644/// budget like any other, and an older version's can fill it.
645fn prune(dir: &Path) {
646    let Ok(read) = std::fs::read_dir(dir) else {
647        return;
648    };
649    let (mut files, mut bytes) = (0usize, 0u64);
650    for entry in read.flatten() {
651        let name = entry.file_name().to_string_lossy().into_owned();
652        if !current(&name) {
653            let len = entry.metadata().map_or(0, |m| m.len());
654            if std::fs::remove_file(entry.path()).is_ok() {
655                files += 1;
656                bytes += len;
657            }
658        }
659    }
660    if files > 0 {
661        log::info!(
662            "covers: removed {files} covers no longer served ({} MB)",
663            bytes >> 20
664        );
665    }
666}
667
668fn ring(changed: &tokio::sync::watch::Sender<u64>) {
669    changed.send_modify(|n| *n = n.wrapping_add(1));
670}
671
672/// Whether `name` is something this version keeps: a dot file, a write in
673/// progress, or a cover at a size it serves, kept by album or by source.
674fn current(name: &str) -> bool {
675    name.starts_with('.') || name.ends_with(".tmp") || parse(name).is_some()
676}
677
678/// A cover's record, when it is kept by record, and size; `None` for
679/// anything that is not a cover at a size this version serves.
680fn parse(name: &str) -> Option<(Option<i64>, u32)> {
681    let (head, size) = name.strip_suffix(".jpg")?.rsplit_once('-')?;
682    let size = size.parse().ok().filter(|size| SIZES.contains(size))?;
683    match head.strip_prefix("album-") {
684        Some(id) => Some((Some(id.parse().ok()?), size)),
685        None => (head.len() == 32 && head.bytes().all(|b| b.is_ascii_hexdigit()))
686            .then_some((None, size)),
687    }
688}
689
690/// What the covers in `dir` take up.
691fn dir_bytes(dir: &Path) -> u64 {
692    std::fs::read_dir(dir)
693        .map(|read| {
694            read.flatten()
695                .filter_map(|e| e.metadata().ok())
696                .map(|m| m.len())
697                .sum()
698        })
699        .unwrap_or(0)
700}
701
702/// Whether any of `tracks`' files can be read: when none can (a volume not
703/// mounted, a download not landed), not finding art says nothing about the
704/// album, and is not remembered.
705pub(crate) fn readable(tracks: &[TrackRow]) -> bool {
706    tracks
707        .iter()
708        .filter_map(crate::subsonic::track_file_path)
709        .take(TRACKS_TRIED)
710        .any(|p| Path::new(&p).exists())
711}
712
713/// Hold `dir` to `budget` bytes, removing the covers read least recently
714/// until it is a tenth under, and every temporary file older than `TMP_LEFT`.
715/// Covers removed and their bytes, when it was over the budget.
716fn sweep(dir: &Path, budget: u64) -> Option<(usize, u64)> {
717    let now = std::time::SystemTime::now();
718    let read = std::fs::read_dir(dir).ok()?;
719    let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = Vec::new();
720    for entry in read.flatten() {
721        let Ok(meta) = entry.metadata() else { continue };
722        let modified = meta.modified().unwrap_or(now);
723        let path = entry.path();
724        if entry.file_name().to_string_lossy().starts_with('.') {
725            continue;
726        }
727        if path.extension().is_some_and(|e| e == "tmp") {
728            if now.duration_since(modified).unwrap_or_default() > TMP_LEFT {
729                let _ = std::fs::remove_file(&path);
730            }
731            continue;
732        }
733        kept.push((modified, meta.len(), path));
734    }
735    let mut total: u64 = kept.iter().map(|(_, len, _)| len).sum();
736    if total <= budget {
737        return None;
738    }
739    kept.sort_by_key(|(modified, _, _)| *modified);
740    let target = budget - budget / 10;
741    let (mut removed, mut bytes) = (0, 0);
742    for (_, len, path) in kept {
743        if total <= target {
744            break;
745        }
746        if std::fs::remove_file(&path).is_ok() {
747            total -= len;
748            removed += 1;
749            bytes += len;
750        }
751    }
752    Some((removed, bytes))
753}
754
755/// Where a cover is read from.
756enum Source {
757    /// An image file beside the track.
758    Image(PathBuf),
759    /// The art embedded in the track itself.
760    Embedded(PathBuf),
761}
762
763impl Source {
764    fn path(&self) -> &Path {
765        match self {
766            Self::Image(p) | Self::Embedded(p) => p,
767        }
768    }
769}
770
771/// The source's path, size and mtime, and the size asked for: a re-tagged or
772/// replaced file is a different key, and stale entries are simply never read.
773fn key(path: &Path, size: u32) -> String {
774    let meta = std::fs::metadata(path).ok();
775    let mtime = meta
776        .as_ref()
777        .and_then(|m| m.modified().ok())
778        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
779        .map_or(0, |d| d.as_secs());
780    let len = meta.map_or(0, |m| m.len());
781    let digest = md5::compute(format!("{}\0{len}\0{mtime}", path.display()));
782    format!("{digest:x}-{size}.jpg")
783}
784
785/// An album's own entry: what `album_cover` reads before any file.
786fn album_key(album: i64, size: u32) -> String {
787    format!("album-{album}-{size}.jpg")
788}
789
790/// Fit within `size` pixels on a side and encode as JPEG. A JPEG already
791/// within bounds is passed through untouched.
792fn encode(bytes: &[u8], size: u32) -> Option<Vec<u8>> {
793    use image::GenericImageView as _;
794    let img = image::load_from_memory(bytes).ok()?;
795    let (w, h) = img.dimensions();
796    if w.max(h) <= size && bytes.starts_with(&[0xFF, 0xD8]) {
797        return Some(bytes.to_vec());
798    }
799    let img = if w.max(h) > size {
800        img.thumbnail(size, size)
801    } else {
802        img
803    };
804    let mut out = Vec::new();
805    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
806        .encode_image(&img.to_rgb8())
807        .ok()?;
808    Some(out)
809}
810
811/// The ensō on a `surface` square, as PNG: what a record without art is
812/// served, so every client draws an image. The apps' placeholder geometry —
813/// the mark fitted inside a 26% inset, stroked at 4.5% of the side — in the
814/// dark appearance's `muted` on `surface`, which reads as a dark sleeve in a
815/// light client too. Drawn once per size and kept.
816pub(crate) fn placeholder(size: u32) -> Bytes {
817    static DRAWN: [std::sync::OnceLock<Bytes>; SIZES.len()] =
818        [const { std::sync::OnceLock::new() }; SIZES.len()];
819    let size = snap(Some(size));
820    let slot = SIZES
821        .iter()
822        .position(|s| *s == size)
823        .expect("snap answers one of SIZES");
824    DRAWN[slot].get_or_init(|| draw_placeholder(size)).clone()
825}
826
827fn draw_placeholder(size: u32) -> Bytes {
828    // The dark appearance's `--surface` (#2a2a2a) and `--muted` (#919191),
829    // from the @radiosilence/theme package.
830    const SURFACE: [f32; 3] = [42.0, 42.0, 42.0];
831    const MUTED: [f32; 3] = [145.0, 145.0, 145.0];
832    // `just icons` writes each glyph as absolute `M`/`L` polylines, which is
833    // all this reads; the test below fails if that changes.
834    // The glyph's 24-unit box, with the ring spanning 2.5–21.5, scaled so the
835    // ring is as wide as the apps' (60% of the inset box).
836    let side = size as f32;
837    let scale = side * 0.48 * 0.6 / 19.0;
838    let points: Vec<(f32, f32)> = crate::icon::ENSO
839        .strokes
840        .split(['M', 'L'])
841        .filter_map(|p| {
842            let (x, y) = p.split_once(' ')?;
843            let (x, y): (f32, f32) = (x.trim().parse().ok()?, y.trim().parse().ok()?);
844            Some((
845                side / 2.0 + (x - 12.0) * scale,
846                side / 2.0 + (y - 12.0) * scale,
847            ))
848        })
849        .collect();
850    let radius = side * 0.045 / 2.0;
851    let mut img = image::RgbImage::from_pixel(size, size, image::Rgb(SURFACE.map(|c| c as u8)));
852    // Only the pixels the stroke can reach are measured.
853    let (mut lo, mut hi) = (side, 0.0f32);
854    for &(x, y) in &points {
855        lo = lo.min(x.min(y) - radius - 1.0);
856        hi = hi.max(x.max(y) + radius + 1.0);
857    }
858    let span = (lo.max(0.0) as u32)..(hi.min(side) as u32);
859    for y in span.clone() {
860        for x in span.clone() {
861            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
862            let distance = points
863                .windows(2)
864                .map(|w| {
865                    let ((ax, ay), (bx, by)) = (w[0], w[1]);
866                    let (dx, dy) = (bx - ax, by - ay);
867                    let t =
868                        (((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
869                    (px - ax - t * dx).hypot(py - ay - t * dy)
870                })
871                .fold(f32::INFINITY, f32::min);
872            let cover = (radius + 0.5 - distance).clamp(0.0, 1.0);
873            if cover > 0.0 {
874                let mix = |i: usize| (SURFACE[i] + (MUTED[i] - SURFACE[i]) * cover).round() as u8;
875                img.put_pixel(x, y, image::Rgb([mix(0), mix(1), mix(2)]));
876            }
877        }
878    }
879    let mut out = std::io::Cursor::new(Vec::new());
880    img.write_to(&mut out, image::ImageFormat::Png)
881        .expect("PNG encodes in memory");
882    Bytes::from(out.into_inner())
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888
889    /// Covers at sizes no longer served, and strangers, go; what this version
890    /// reads stays.
891    #[test]
892    fn covers_no_longer_served_are_pruned() {
893        let dir = tempfile::tempdir().unwrap();
894        let hash = "0123456789abcdef0123456789abcdef";
895        let keep = [
896            "album-12-600.jpg".to_string(),
897            format!("{hash}-300.jpg"),
898            ".evicted".to_string(),
899            "album-1-600.jpg.42-7.tmp".to_string(),
900        ];
901        let go = [
902            format!("{hash}-800.jpg"),
903            "album-12-400.jpg".to_string(),
904            "notes.txt".to_string(),
905        ];
906        for name in keep.iter().chain(&go) {
907            std::fs::write(dir.path().join(name), b"x").unwrap();
908        }
909        prune(dir.path());
910        for name in &keep {
911            assert!(dir.path().join(name).exists(), "{name} kept");
912        }
913        for name in &go {
914            assert!(!dir.path().join(name).exists(), "{name} removed");
915        }
916    }
917
918    /// An album's entry answers without its files: `make` runs once, a miss
919    /// is kept like a hit, and a `make` that could not tell keeps nothing.
920    #[test]
921    fn an_album_is_read_from_its_files_once() {
922        let dir = tempfile::tempdir().unwrap();
923        let covers = Covers::new(dir.path().to_path_buf());
924        let made = std::cell::Cell::new(0);
925        let make = |art: Option<Bytes>| {
926            made.set(made.get() + 1);
927            Some(art)
928        };
929        let jpeg = Bytes::from_static(b"jpeg");
930        assert_eq!(
931            covers.album_cover(1, GRID, || make(Some(jpeg.clone()))),
932            Some(jpeg.clone())
933        );
934        assert_eq!(covers.album_cover(1, GRID, || make(None)), Some(jpeg));
935        assert_eq!(covers.album_cover(2, GRID, || make(None)), None);
936        assert_eq!(
937            covers.album_cover(2, GRID, || make(Some(Bytes::new()))),
938            None
939        );
940        assert_eq!(made.get(), 2);
941        assert_eq!(covers.album_cover(3, GRID, || None), None);
942        assert!(!dir.path().join(album_key(3, GRID)).exists());
943    }
944
945    /// A database made afresh numbers its evictions from the start again, and
946    /// gives old albums' ids to new ones: every album's entry goes.
947    #[test]
948    fn a_new_database_drops_every_album() {
949        let dir = tempfile::tempdir().unwrap();
950        std::fs::create_dir_all(dir.path()).unwrap();
951        std::fs::write(dir.path().join(EVICTED), "500").unwrap();
952        let conn = rusqlite::Connection::open_in_memory().unwrap();
953        conn.execute_batch(
954            "CREATE TABLE art_evictions (seq INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, id INTEGER);
955             INSERT INTO art_evictions (kind, id) VALUES ('track', 9);",
956        )
957        .unwrap();
958        let covers = Covers::new(dir.path().to_path_buf());
959        covers.album_cover(1, GRID, || Some(Some(Bytes::from_static(b"x"))));
960        covers.forget_evicted(&conn);
961        assert!(!dir.path().join(album_key(1, GRID)).exists());
962        assert_eq!(
963            std::fs::read_to_string(dir.path().join(EVICTED)).unwrap(),
964            "1"
965        );
966    }
967
968    /// Albums the library names after the first look lose their entries;
969    /// others keep theirs.
970    #[test]
971    fn evicted_albums_are_read_again() {
972        let dir = tempfile::tempdir().unwrap();
973        let conn = rusqlite::Connection::open_in_memory().unwrap();
974        conn.execute_batch(
975            "CREATE TABLE art_evictions (seq INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, id INTEGER);
976             INSERT INTO art_evictions (kind, id) VALUES ('album', 1);",
977        )
978        .unwrap();
979        let covers = Covers::new(dir.path().to_path_buf());
980        let keep = |album| covers.album_cover(album, GRID, || Some(Some(Bytes::from_static(b"x"))));
981        keep(1);
982        keep(2);
983        // The first look only notes where the library is.
984        covers.forget_evicted(&conn);
985        assert!(dir.path().join(album_key(1, GRID)).exists());
986        conn.execute_batch(
987            "INSERT INTO art_evictions (kind, id) VALUES ('album', 2), ('track', 1);",
988        )
989        .unwrap();
990        covers.forget_evicted(&conn);
991        assert!(dir.path().join(album_key(1, GRID)).exists());
992        assert!(!dir.path().join(album_key(2, GRID)).exists());
993        // Where it got to is kept across restarts.
994        assert_eq!(
995            Covers::new(dir.path().to_path_buf())
996                .evicted
997                .lock()
998                .unwrap(),
999            3
1000        );
1001    }
1002
1003    /// The report counts each size, its misses, and the records still
1004    /// lacking a warmed size; a clear empties all but the eviction mark.
1005    #[test]
1006    fn usage_counts_sizes_and_records_left_to_warm_and_a_clear_empties_it() {
1007        let dir = tempfile::tempdir().unwrap();
1008        let conn = rusqlite::Connection::open_in_memory().unwrap();
1009        conn.execute_batch(
1010            "CREATE TABLE tracks (id INTEGER PRIMARY KEY, album_id INTEGER, path TEXT);
1011             INSERT INTO tracks (album_id, path) VALUES (1, '/a'), (2, '/b'), (3, NULL);",
1012        )
1013        .unwrap();
1014        let covers = Covers::new(dir.path().to_path_buf());
1015        let hash = "0123456789abcdef0123456789abcdef";
1016        for (name, len) in [
1017            ("album-1-300.jpg", 100),
1018            ("album-1-600.jpg", 0),
1019            ("album-2-600.jpg", 400),
1020            (&format!("{hash}-1200.jpg") as &str, 1000),
1021            (".evicted", 2),
1022        ] {
1023            std::fs::write(dir.path().join(name), vec![0u8; len]).unwrap();
1024        }
1025        let u = covers.usage(&conn);
1026        assert_eq!(u.bytes, 1502);
1027        assert_eq!(u.budget, BUDGET);
1028        let at = |size| *u.sizes.iter().find(|s| s.size == size).unwrap();
1029        assert_eq!(at(150).covers, 0);
1030        assert_eq!((at(300).covers, at(300).bytes, at(300).misses), (1, 100, 0));
1031        assert_eq!((at(600).covers, at(600).bytes, at(600).misses), (2, 400, 1));
1032        assert_eq!(at(1200).covers, 1);
1033        assert_eq!((u.covers(), u.misses()), (4, 1));
1034        assert_eq!(u.unwarmed, 1, "record 2 lacks its 300; 3 has no files");
1035
1036        let changed = covers.subscribe();
1037        assert_eq!(covers.clear(), (4, 1500));
1038        assert!(changed.has_changed().unwrap());
1039        let left: Vec<_> = std::fs::read_dir(dir.path())
1040            .unwrap()
1041            .map(|e| e.unwrap().file_name())
1042            .collect();
1043        assert_eq!(left, [".evicted"]);
1044        assert_eq!(covers.usage(&conn).unwarmed, 2);
1045    }
1046
1047    /// A warm reports its pass as it goes and when it ends; one not
1048    /// started refuses to be woken.
1049    #[test]
1050    fn the_warm_reports_its_pass() {
1051        let dir = tempfile::tempdir().unwrap();
1052        let covers = Covers::new(dir.path().join("covers"));
1053        assert_eq!(covers.report().warm.state, WarmState::Off);
1054        assert!(!covers.warm_now());
1055        let db = dir.path().join("koan.db");
1056        koan_core::db::connection::Database::open(&db).unwrap();
1057        let pool = koan_core::db::pool::Pool::new(db);
1058        covers.warm_pass(&pool);
1059        let warm = covers.report().warm;
1060        assert_eq!(warm.state, WarmState::Idle);
1061        assert_eq!((warm.done, warm.total, warm.made), (0, 0, 0));
1062        assert!(warm.started.is_some() && warm.finished.is_some());
1063        assert_eq!(warm.next(), Some(warm.finished.unwrap() + WARM_EVERY));
1064    }
1065
1066    #[test]
1067    fn requests_snap_up_to_a_served_size() {
1068        assert_eq!(snap(Some(1)), 150);
1069        assert_eq!(snap(Some(300)), 300);
1070        assert_eq!(snap(Some(301)), 600);
1071        assert_eq!(snap(Some(1000)), 1200);
1072        assert_eq!(snap(Some(99_999)), 1200);
1073        assert_eq!(snap(None), LARGE);
1074    }
1075
1076    #[test]
1077    fn the_enso_is_one_absolute_polyline() {
1078        let strokes = crate::icon::ENSO.strokes;
1079        assert!(strokes.starts_with('M'));
1080        assert_eq!(strokes.matches('M').count(), 1);
1081        assert!(
1082            strokes
1083                .chars()
1084                .all(|c| matches!(c, 'M' | 'L' | ' ' | '.' | '-' | '0'..='9'))
1085        );
1086    }
1087
1088    #[test]
1089    fn the_placeholder_is_the_size_asked_with_the_mark_inside_the_inset() {
1090        let png = placeholder(300);
1091        let img = image::load_from_memory(&png).unwrap().to_rgb8();
1092        assert_eq!(img.dimensions(), (300, 300));
1093        let surface = image::Rgb([42, 42, 42]);
1094        assert_eq!(*img.get_pixel(0, 0), surface);
1095        assert_eq!(*img.get_pixel(150, 150), surface, "the ring is open");
1096        let inked: Vec<_> = img
1097            .enumerate_pixels()
1098            .filter(|(_, _, p)| **p != surface)
1099            .collect();
1100        assert!(!inked.is_empty());
1101        let inset = (300.0 * 0.26) as u32;
1102        assert!(
1103            inked
1104                .iter()
1105                .all(|(x, y, _)| (inset..300 - inset).contains(x)
1106                    && (inset..300 - inset).contains(y))
1107        );
1108    }
1109
1110    #[test]
1111    fn writers_racing_on_one_cover_leave_it_whole() {
1112        let dir = tempfile::tempdir().unwrap();
1113        let covers = Covers::new(dir.path().to_path_buf());
1114        let arts: Vec<Vec<u8>> = (0..8u8).map(|i| vec![i; 256 * 1024]).collect();
1115        for _ in 0..20 {
1116            std::thread::scope(|s| {
1117                for art in &arts {
1118                    s.spawn(|| covers.write_disk("k-400.jpg", Some(art)));
1119                }
1120            });
1121            let got = std::fs::read(dir.path().join("k-400.jpg")).unwrap();
1122            assert!(arts.contains(&got), "a torn write of {} bytes", got.len());
1123        }
1124        let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
1125        assert_eq!(left.len(), 1, "no temporary files left behind");
1126    }
1127
1128    #[test]
1129    fn the_directory_is_held_to_its_budget_oldest_read_first() {
1130        let dir = tempfile::tempdir().unwrap();
1131        let day = std::time::Duration::from_secs(24 * 60 * 60);
1132        let now = std::time::SystemTime::now();
1133        let put = |name: &str, len: usize, age: u32| {
1134            let path = dir.path().join(name);
1135            std::fs::write(&path, vec![0u8; len]).unwrap();
1136            std::fs::File::options()
1137                .write(true)
1138                .open(&path)
1139                .unwrap()
1140                .set_modified(now - day * age)
1141                .unwrap();
1142        };
1143        for i in 0..10u32 {
1144            put(&format!("c{i}-400.jpg"), 1000, 10 - i);
1145        }
1146        put("c0-400.jpg.1-2.tmp", 10, 2);
1147        put("c9-400.jpg.1-3.tmp", 10, 0);
1148        assert_eq!(sweep(dir.path(), 5600), Some((5, 5000)));
1149        let mut left: Vec<String> = std::fs::read_dir(dir.path())
1150            .unwrap()
1151            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1152            .collect();
1153        left.sort();
1154        assert_eq!(
1155            left,
1156            [
1157                "c5-400.jpg",
1158                "c6-400.jpg",
1159                "c7-400.jpg",
1160                "c8-400.jpg",
1161                "c9-400.jpg",
1162                "c9-400.jpg.1-3.tmp"
1163            ],
1164            "the five read longest ago go, and the abandoned temporary file"
1165        );
1166    }
1167
1168    #[test]
1169    fn covers_are_bounded_jpegs() {
1170        use image::GenericImageView as _;
1171        let png = {
1172            let mut out = std::io::Cursor::new(Vec::new());
1173            image::DynamicImage::new_rgba8(2400, 1200)
1174                .write_to(&mut out, image::ImageFormat::Png)
1175                .unwrap();
1176            out.into_inner()
1177        };
1178        let out = encode(&png, 400).unwrap();
1179        assert!(out.starts_with(&[0xFF, 0xD8]));
1180        assert_eq!(
1181            image::load_from_memory(&out).unwrap().dimensions(),
1182            (400, 200)
1183        );
1184
1185        let small = encode(&png, 400).unwrap();
1186        assert_eq!(
1187            encode(&small, 800).unwrap(),
1188            small,
1189            "a small JPEG passes through"
1190        );
1191    }
1192}