Skip to main content

koan_server/
covers.rs

1//! Album covers for the web UI and share pages: embedded art, resized to one
2//! of a few sizes, encoded as JPEG, and kept.
3//!
4//! Reading embedded art means opening the audio file and parsing its tags, and
5//! the art itself is often megabytes; doing that per tile made an albums grid
6//! pull tens of megabytes and seconds of server time. Resized covers are kept
7//! on disk under the config directory, keyed by the source file's path, size
8//! and mtime so a re-tag is a new key. An album with no art is remembered too,
9//! so a grid of them does not reopen every file on every visit.
10//!
11//! Nothing is held in memory: the kernel's page cache keeps hot files for
12//! free and gives the memory back under pressure, where a cache of our own
13//! would count against the process for as long as it runs. Decoding is the
14//! expensive part — a full-size cover is tens of megabytes of pixels — so it
15//! runs on a few threads of its own. A grid opening asks for hundreds of covers
16//! at once; on the blocking pool each request would decode on its own thread,
17//! and the allocator keeps what every one of those threads used.
18
19use std::path::{Path, PathBuf};
20use std::sync::LazyLock;
21
22use axum::body::Bytes;
23
24use koan_core::db::queries::TrackRow;
25
26/// The sizes a cover is served at. A request is rounded up to one of these,
27/// which bounds what the cache can hold per album.
28pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
29/// Grid tiles, at 2x.
30pub(crate) const GRID: u32 = 400;
31/// Headers, the player, and link previews.
32pub(crate) const LARGE: u32 = 800;
33
34/// How many of an album's tracks are opened looking for art before giving up.
35const TRACKS_TRIED: usize = 3;
36const JPEG_QUALITY: u8 = 85;
37
38pub(crate) fn snap(size: Option<u32>) -> u32 {
39    let size = size.unwrap_or(LARGE);
40    SIZES
41        .into_iter()
42        .find(|s| *s >= size)
43        .unwrap_or(SIZES[SIZES.len() - 1])
44}
45
46/// The threads covers are decoded and resized on.
47static DECODE: LazyLock<rayon::ThreadPool> = LazyLock::new(|| {
48    let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(1, 4));
49    rayon::ThreadPoolBuilder::new()
50        .num_threads(threads)
51        .thread_name(|i| format!("koan-covers-{i}"))
52        .build()
53        .expect("cover threads")
54});
55
56pub struct Covers {
57    dir: PathBuf,
58}
59
60impl Covers {
61    pub fn new(dir: PathBuf) -> Self {
62        Self { dir }
63    }
64
65    /// Covers kept under koan's config directory.
66    pub fn in_config_dir() -> Self {
67        Self::new(koan_core::config::config_dir().join("covers"))
68    }
69
70    /// A cover from the first of `tracks` that has art, at `size` (one of
71    /// `SIZES`), as JPEG. Blocking: call it off the async workers.
72    pub(crate) fn cover(&self, tracks: &[TrackRow], size: u32) -> Option<Bytes> {
73        let sources: Vec<(PathBuf, String)> = tracks
74            .iter()
75            .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
76            .take(TRACKS_TRIED)
77            .map(|p| {
78                let key = key(&p, size);
79                (p, key)
80            })
81            .collect();
82        // Keyed on the first candidate: that is the file whose art is shown
83        // whenever it has any.
84        let (_, first_key) = sources.first()?;
85        if let Some(hit) = self.read_disk(first_key) {
86            return hit;
87        }
88        let art = DECODE.install(|| {
89            sources.iter().find_map(|(p, _)| {
90                koan_core::index::metadata::extract_cover_art(p)
91                    .and_then(|bytes| encode(&bytes, size))
92            })
93        });
94        self.write_disk(first_key, art.as_deref());
95        art.map(Bytes::from)
96    }
97
98    /// `Some(None)` is a remembered miss.
99    fn read_disk(&self, key: &str) -> Option<Option<Bytes>> {
100        let bytes = std::fs::read(self.dir.join(key)).ok()?;
101        Some((!bytes.is_empty()).then(|| Bytes::from(bytes)))
102    }
103
104    /// An empty file records that there is no art. Failure to write is only a
105    /// lost cache entry. Requests that miss the same cover together each write
106    /// a temporary file of their own, so the one renamed into place last is
107    /// whole rather than another's half-written file.
108    fn write_disk(&self, key: &str, art: Option<&[u8]>) {
109        static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
110        let _ = std::fs::create_dir_all(&self.dir);
111        let n = WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
112        let tmp = self
113            .dir
114            .join(format!("{key}.{}-{n}.tmp", std::process::id()));
115        if std::fs::write(&tmp, art.unwrap_or_default()).is_ok()
116            && std::fs::rename(&tmp, self.dir.join(key)).is_err()
117        {
118            let _ = std::fs::remove_file(&tmp);
119        }
120    }
121}
122
123/// The source's path, size and mtime, and the size asked for: a re-tagged or
124/// replaced file is a different key, and stale entries are simply never read.
125fn key(path: &Path, size: u32) -> String {
126    let meta = std::fs::metadata(path).ok();
127    let mtime = meta
128        .as_ref()
129        .and_then(|m| m.modified().ok())
130        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
131        .map_or(0, |d| d.as_secs());
132    let len = meta.map_or(0, |m| m.len());
133    let digest = md5::compute(format!("{}\0{len}\0{mtime}", path.display()));
134    format!("{digest:x}-{size}.jpg")
135}
136
137/// Fit within `size` pixels on a side and encode as JPEG. A JPEG already
138/// within bounds is passed through untouched.
139fn encode(bytes: &[u8], size: u32) -> Option<Vec<u8>> {
140    use image::GenericImageView as _;
141    let img = image::load_from_memory(bytes).ok()?;
142    let (w, h) = img.dimensions();
143    if w.max(h) <= size && bytes.starts_with(&[0xFF, 0xD8]) {
144        return Some(bytes.to_vec());
145    }
146    let img = if w.max(h) > size {
147        img.thumbnail(size, size)
148    } else {
149        img
150    };
151    let mut out = Vec::new();
152    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
153        .encode_image(&img.to_rgb8())
154        .ok()?;
155    Some(out)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn requests_snap_up_to_a_served_size() {
164        assert_eq!(snap(Some(1)), 200);
165        assert_eq!(snap(Some(400)), 400);
166        assert_eq!(snap(Some(401)), 800);
167        assert_eq!(snap(Some(99_999)), 1200);
168        assert_eq!(snap(None), LARGE);
169    }
170
171    #[test]
172    fn writers_racing_on_one_cover_leave_it_whole() {
173        let dir = tempfile::tempdir().unwrap();
174        let covers = Covers::new(dir.path().to_path_buf());
175        let arts: Vec<Vec<u8>> = (0..8u8).map(|i| vec![i; 256 * 1024]).collect();
176        for _ in 0..20 {
177            std::thread::scope(|s| {
178                for art in &arts {
179                    s.spawn(|| covers.write_disk("k-400.jpg", Some(art)));
180                }
181            });
182            let got = std::fs::read(dir.path().join("k-400.jpg")).unwrap();
183            assert!(arts.contains(&got), "a torn write of {} bytes", got.len());
184        }
185        let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
186        assert_eq!(left.len(), 1, "no temporary files left behind");
187    }
188
189    #[test]
190    fn covers_are_bounded_jpegs() {
191        use image::GenericImageView as _;
192        let png = {
193            let mut out = std::io::Cursor::new(Vec::new());
194            image::DynamicImage::new_rgba8(2400, 1200)
195                .write_to(&mut out, image::ImageFormat::Png)
196                .unwrap();
197            out.into_inner()
198        };
199        let out = encode(&png, 400).unwrap();
200        assert!(out.starts_with(&[0xFF, 0xD8]));
201        assert_eq!(
202            image::load_from_memory(&out).unwrap().dimensions(),
203            (400, 200)
204        );
205
206        let small = encode(&png, 400).unwrap();
207        assert_eq!(
208            encode(&small, 800).unwrap(),
209            small,
210            "a small JPEG passes through"
211        );
212    }
213}