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.
12//!
13//! Nothing is held in memory: the kernel's page cache keeps hot files for
14//! free and gives the memory back under pressure, where a cache of our own
15//! would count against the process for as long as it runs. Decoding is the
16//! expensive part — a full-size cover is tens of megabytes of pixels — so it
17//! runs on a few threads of its own. A grid opening asks for hundreds of covers
18//! at once; on the blocking pool each request would decode on its own thread,
19//! and the allocator keeps what every one of those threads used.
20
21use std::path::{Path, PathBuf};
22use std::sync::LazyLock;
23
24use axum::body::Bytes;
25
26use koan_core::db::queries::TrackRow;
27
28/// The sizes a cover is served at. A request is rounded up to one of these,
29/// which bounds what the cache can hold per album.
30pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
31/// Grid tiles, at 2x.
32pub(crate) const GRID: u32 = 400;
33/// Headers, the player, and link previews.
34pub(crate) const LARGE: u32 = 800;
35
36/// How many of an album's tracks are opened looking for art before giving up.
37const TRACKS_TRIED: usize = 3;
38const JPEG_QUALITY: u8 = 85;
39
40pub(crate) fn snap(size: Option<u32>) -> u32 {
41    let size = size.unwrap_or(LARGE);
42    SIZES
43        .into_iter()
44        .find(|s| *s >= size)
45        .unwrap_or(SIZES[SIZES.len() - 1])
46}
47
48/// The threads covers are decoded and resized on.
49static DECODE: LazyLock<rayon::ThreadPool> = LazyLock::new(|| {
50    let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(1, 4));
51    rayon::ThreadPoolBuilder::new()
52        .num_threads(threads)
53        .thread_name(|i| format!("koan-covers-{i}"))
54        .build()
55        .expect("cover threads")
56});
57
58pub struct Covers {
59    dir: PathBuf,
60}
61
62impl Covers {
63    pub fn new(dir: PathBuf) -> Self {
64        Self { dir }
65    }
66
67    /// Covers kept under koan's config directory.
68    pub fn in_config_dir() -> Self {
69        Self::new(koan_core::config::config_dir().join("covers"))
70    }
71
72    /// A cover from the first of `tracks` that has art, at `size` (one of
73    /// `SIZES`), as JPEG. An image beside a track comes before the art
74    /// embedded in it (see `koan_core::index::folder_art`). Blocking: call it
75    /// off the async workers.
76    pub(crate) fn cover(&self, tracks: &[TrackRow], size: u32) -> Option<Bytes> {
77        let sources: Vec<(Source, String)> = tracks
78            .iter()
79            .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
80            .take(TRACKS_TRIED)
81            .map(|p| {
82                let source = match koan_core::index::folder_art::folder_cover(&p) {
83                    Some(image) => Source::Image(image),
84                    None => Source::Embedded(p),
85                };
86                let key = key(source.path(), size);
87                (source, key)
88            })
89            .collect();
90        // Keyed on the first candidate: that is the file whose art is shown
91        // whenever it has any. An image added beside it is a new key, so a
92        // remembered miss does not outlive it.
93        let (_, first_key) = sources.first()?;
94        if let Some(hit) = self.read_disk(first_key) {
95            return hit;
96        }
97        let art = DECODE.install(|| {
98            sources.iter().find_map(|(source, _)| {
99                let bytes = match source {
100                    Source::Image(p) => std::fs::read(p).ok(),
101                    Source::Embedded(p) => koan_core::index::metadata::extract_cover_art(p),
102                };
103                bytes.and_then(|bytes| encode(&bytes, size))
104            })
105        });
106        self.write_disk(first_key, art.as_deref());
107        art.map(Bytes::from)
108    }
109
110    /// `Some(None)` is a remembered miss.
111    fn read_disk(&self, key: &str) -> Option<Option<Bytes>> {
112        let bytes = std::fs::read(self.dir.join(key)).ok()?;
113        Some((!bytes.is_empty()).then(|| Bytes::from(bytes)))
114    }
115
116    /// An empty file records that there is no art. Failure to write is only a
117    /// lost cache entry. Requests that miss the same cover together each write
118    /// a temporary file of their own, so the one renamed into place last is
119    /// whole rather than another's half-written file.
120    fn write_disk(&self, key: &str, art: Option<&[u8]>) {
121        static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
122        let _ = std::fs::create_dir_all(&self.dir);
123        let n = WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
124        let tmp = self
125            .dir
126            .join(format!("{key}.{}-{n}.tmp", std::process::id()));
127        if std::fs::write(&tmp, art.unwrap_or_default()).is_ok()
128            && std::fs::rename(&tmp, self.dir.join(key)).is_err()
129        {
130            let _ = std::fs::remove_file(&tmp);
131        }
132    }
133}
134
135/// Where a cover is read from.
136enum Source {
137    /// An image file beside the track.
138    Image(PathBuf),
139    /// The art embedded in the track itself.
140    Embedded(PathBuf),
141}
142
143impl Source {
144    fn path(&self) -> &Path {
145        match self {
146            Self::Image(p) | Self::Embedded(p) => p,
147        }
148    }
149}
150
151/// The source's path, size and mtime, and the size asked for: a re-tagged or
152/// replaced file is a different key, and stale entries are simply never read.
153fn key(path: &Path, size: u32) -> String {
154    let meta = std::fs::metadata(path).ok();
155    let mtime = meta
156        .as_ref()
157        .and_then(|m| m.modified().ok())
158        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
159        .map_or(0, |d| d.as_secs());
160    let len = meta.map_or(0, |m| m.len());
161    let digest = md5::compute(format!("{}\0{len}\0{mtime}", path.display()));
162    format!("{digest:x}-{size}.jpg")
163}
164
165/// Fit within `size` pixels on a side and encode as JPEG. A JPEG already
166/// within bounds is passed through untouched.
167fn encode(bytes: &[u8], size: u32) -> Option<Vec<u8>> {
168    use image::GenericImageView as _;
169    let img = image::load_from_memory(bytes).ok()?;
170    let (w, h) = img.dimensions();
171    if w.max(h) <= size && bytes.starts_with(&[0xFF, 0xD8]) {
172        return Some(bytes.to_vec());
173    }
174    let img = if w.max(h) > size {
175        img.thumbnail(size, size)
176    } else {
177        img
178    };
179    let mut out = Vec::new();
180    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
181        .encode_image(&img.to_rgb8())
182        .ok()?;
183    Some(out)
184}
185
186/// The ensō on a `surface` square, as PNG: what a record without art is
187/// served, so every client draws an image. The apps' placeholder geometry —
188/// the mark fitted inside a 26% inset, stroked at 4.5% of the side — in the
189/// dark appearance's `muted` on `surface`, which reads as a dark sleeve in a
190/// light client too. Drawn once per size and kept.
191pub(crate) fn placeholder(size: u32) -> Bytes {
192    static DRAWN: [std::sync::OnceLock<Bytes>; SIZES.len()] =
193        [const { std::sync::OnceLock::new() }; SIZES.len()];
194    let size = snap(Some(size));
195    let slot = SIZES
196        .iter()
197        .position(|s| *s == size)
198        .expect("snap answers one of SIZES");
199    DRAWN[slot].get_or_init(|| draw_placeholder(size)).clone()
200}
201
202fn draw_placeholder(size: u32) -> Bytes {
203    // The dark appearance's `--surface` (#2a2a2a) and `--muted` (#919191),
204    // from site/src/theme.css.
205    const SURFACE: [f32; 3] = [42.0, 42.0, 42.0];
206    const MUTED: [f32; 3] = [145.0, 145.0, 145.0];
207    // `just icons` writes each glyph as absolute `M`/`L` polylines, which is
208    // all this reads; the test below fails if that changes.
209    // The glyph's 24-unit box, with the ring spanning 2.5–21.5, scaled so the
210    // ring is as wide as the apps' (60% of the inset box).
211    let side = size as f32;
212    let scale = side * 0.48 * 0.6 / 19.0;
213    let points: Vec<(f32, f32)> = crate::icon::ENSO
214        .strokes
215        .split(['M', 'L'])
216        .filter_map(|p| {
217            let (x, y) = p.split_once(' ')?;
218            let (x, y): (f32, f32) = (x.trim().parse().ok()?, y.trim().parse().ok()?);
219            Some((
220                side / 2.0 + (x - 12.0) * scale,
221                side / 2.0 + (y - 12.0) * scale,
222            ))
223        })
224        .collect();
225    let radius = side * 0.045 / 2.0;
226    let mut img = image::RgbImage::from_pixel(size, size, image::Rgb(SURFACE.map(|c| c as u8)));
227    // Only the pixels the stroke can reach are measured.
228    let (mut lo, mut hi) = (side, 0.0f32);
229    for &(x, y) in &points {
230        lo = lo.min(x.min(y) - radius - 1.0);
231        hi = hi.max(x.max(y) + radius + 1.0);
232    }
233    let span = (lo.max(0.0) as u32)..(hi.min(side) as u32);
234    for y in span.clone() {
235        for x in span.clone() {
236            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
237            let distance = points
238                .windows(2)
239                .map(|w| {
240                    let ((ax, ay), (bx, by)) = (w[0], w[1]);
241                    let (dx, dy) = (bx - ax, by - ay);
242                    let t =
243                        (((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
244                    (px - ax - t * dx).hypot(py - ay - t * dy)
245                })
246                .fold(f32::INFINITY, f32::min);
247            let cover = (radius + 0.5 - distance).clamp(0.0, 1.0);
248            if cover > 0.0 {
249                let mix = |i: usize| (SURFACE[i] + (MUTED[i] - SURFACE[i]) * cover).round() as u8;
250                img.put_pixel(x, y, image::Rgb([mix(0), mix(1), mix(2)]));
251            }
252        }
253    }
254    let mut out = std::io::Cursor::new(Vec::new());
255    img.write_to(&mut out, image::ImageFormat::Png)
256        .expect("PNG encodes in memory");
257    Bytes::from(out.into_inner())
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn requests_snap_up_to_a_served_size() {
266        assert_eq!(snap(Some(1)), 200);
267        assert_eq!(snap(Some(400)), 400);
268        assert_eq!(snap(Some(401)), 800);
269        assert_eq!(snap(Some(99_999)), 1200);
270        assert_eq!(snap(None), LARGE);
271    }
272
273    #[test]
274    fn the_enso_is_one_absolute_polyline() {
275        let strokes = crate::icon::ENSO.strokes;
276        assert!(strokes.starts_with('M'));
277        assert_eq!(strokes.matches('M').count(), 1);
278        assert!(
279            strokes
280                .chars()
281                .all(|c| matches!(c, 'M' | 'L' | ' ' | '.' | '-' | '0'..='9'))
282        );
283    }
284
285    #[test]
286    fn the_placeholder_is_the_size_asked_with_the_mark_inside_the_inset() {
287        let png = placeholder(300);
288        let img = image::load_from_memory(&png).unwrap().to_rgb8();
289        assert_eq!(img.dimensions(), (400, 400));
290        let surface = image::Rgb([42, 42, 42]);
291        assert_eq!(*img.get_pixel(0, 0), surface);
292        assert_eq!(*img.get_pixel(200, 200), surface, "the ring is open");
293        let inked: Vec<_> = img
294            .enumerate_pixels()
295            .filter(|(_, _, p)| **p != surface)
296            .collect();
297        assert!(!inked.is_empty());
298        let inset = (400.0 * 0.26) as u32;
299        assert!(
300            inked
301                .iter()
302                .all(|(x, y, _)| (inset..400 - inset).contains(x)
303                    && (inset..400 - inset).contains(y))
304        );
305    }
306
307    #[test]
308    fn writers_racing_on_one_cover_leave_it_whole() {
309        let dir = tempfile::tempdir().unwrap();
310        let covers = Covers::new(dir.path().to_path_buf());
311        let arts: Vec<Vec<u8>> = (0..8u8).map(|i| vec![i; 256 * 1024]).collect();
312        for _ in 0..20 {
313            std::thread::scope(|s| {
314                for art in &arts {
315                    s.spawn(|| covers.write_disk("k-400.jpg", Some(art)));
316                }
317            });
318            let got = std::fs::read(dir.path().join("k-400.jpg")).unwrap();
319            assert!(arts.contains(&got), "a torn write of {} bytes", got.len());
320        }
321        let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
322        assert_eq!(left.len(), 1, "no temporary files left behind");
323    }
324
325    #[test]
326    fn covers_are_bounded_jpegs() {
327        use image::GenericImageView as _;
328        let png = {
329            let mut out = std::io::Cursor::new(Vec::new());
330            image::DynamicImage::new_rgba8(2400, 1200)
331                .write_to(&mut out, image::ImageFormat::Png)
332                .unwrap();
333            out.into_inner()
334        };
335        let out = encode(&png, 400).unwrap();
336        assert!(out.starts_with(&[0xFF, 0xD8]));
337        assert_eq!(
338            image::load_from_memory(&out).unwrap().dimensions(),
339            (400, 200)
340        );
341
342        let small = encode(&png, 400).unwrap();
343        assert_eq!(
344            encode(&small, 800).unwrap(),
345            small,
346            "a small JPEG passes through"
347        );
348    }
349}