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, and the hottest are also held in
9//! memory. An album with no art is remembered too, so a grid of them does not
10//! reopen every file on every visit.
11
12use std::num::NonZeroUsize;
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15
16use axum::body::Bytes;
17
18use koan_core::db::queries::TrackRow;
19use lru::LruCache;
20
21/// The sizes a cover is served at. A request is rounded up to one of these,
22/// which bounds what the cache can hold per album.
23pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
24/// Grid tiles, at 2x.
25pub(crate) const GRID: u32 = 400;
26/// Headers, the player, and link previews.
27pub(crate) const LARGE: u32 = 800;
28
29const MEMORY_ENTRIES: usize = 512;
30/// How many of an album's tracks are opened looking for art before giving up.
31const TRACKS_TRIED: usize = 3;
32const JPEG_QUALITY: u8 = 85;
33
34pub(crate) fn snap(size: Option<u32>) -> u32 {
35    let size = size.unwrap_or(LARGE);
36    SIZES
37        .into_iter()
38        .find(|s| *s >= size)
39        .unwrap_or(SIZES[SIZES.len() - 1])
40}
41
42pub struct Covers {
43    dir: PathBuf,
44    /// `None` is an album with no art.
45    memory: Mutex<LruCache<String, Option<Bytes>>>,
46}
47
48impl Covers {
49    pub fn new(dir: PathBuf) -> Self {
50        Self {
51            dir,
52            memory: Mutex::new(LruCache::new(
53                NonZeroUsize::new(MEMORY_ENTRIES).expect("non-zero"),
54            )),
55        }
56    }
57
58    /// Covers kept under koan's config directory.
59    pub fn in_config_dir() -> Self {
60        Self::new(koan_core::config::config_dir().join("covers"))
61    }
62
63    /// A cover from the first of `tracks` that has art, at `size` (one of
64    /// `SIZES`), as JPEG. Blocking: call it off the async workers.
65    pub(crate) fn cover(&self, tracks: &[TrackRow], size: u32) -> Option<Bytes> {
66        let sources: Vec<(PathBuf, String)> = tracks
67            .iter()
68            .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
69            .take(TRACKS_TRIED)
70            .map(|p| {
71                let key = key(&p, size);
72                (p, key)
73            })
74            .collect();
75        // Keyed on the first candidate: that is the file whose art is shown
76        // whenever it has any.
77        let (_, first_key) = sources.first()?;
78        let first_key = first_key.clone();
79        if let Some(hit) = self.memory.lock().ok()?.get(&first_key).cloned() {
80            return hit;
81        }
82        let found = self.read_disk(&first_key).or_else(|| {
83            let art = sources.iter().find_map(|(p, _)| {
84                koan_core::index::metadata::extract_cover_art(p)
85                    .and_then(|bytes| encode(&bytes, size))
86            });
87            self.write_disk(&first_key, art.as_deref());
88            Some(art.map(Bytes::from))
89        })?;
90        if let Ok(mut memory) = self.memory.lock() {
91            memory.put(first_key, found.clone());
92        }
93        found
94    }
95
96    /// `Some(None)` is a remembered miss.
97    fn read_disk(&self, key: &str) -> Option<Option<Bytes>> {
98        let bytes = std::fs::read(self.dir.join(key)).ok()?;
99        Some((!bytes.is_empty()).then(|| Bytes::from(bytes)))
100    }
101
102    /// An empty file records that there is no art. Failure to write is only a
103    /// lost cache entry.
104    fn write_disk(&self, key: &str, art: Option<&[u8]>) {
105        let _ = std::fs::create_dir_all(&self.dir);
106        let tmp = self.dir.join(format!("{key}.tmp"));
107        if std::fs::write(&tmp, art.unwrap_or_default()).is_ok() {
108            let _ = std::fs::rename(&tmp, self.dir.join(key));
109        }
110    }
111}
112
113/// The source's path, size and mtime, and the size asked for: a re-tagged or
114/// replaced file is a different key, and stale entries are simply never read.
115fn key(path: &Path, size: u32) -> String {
116    let meta = std::fs::metadata(path).ok();
117    let mtime = meta
118        .as_ref()
119        .and_then(|m| m.modified().ok())
120        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
121        .map_or(0, |d| d.as_secs());
122    let len = meta.map_or(0, |m| m.len());
123    let digest = md5::compute(format!("{}\0{len}\0{mtime}", path.display()));
124    format!("{digest:x}-{size}.jpg")
125}
126
127/// Fit within `size` pixels on a side and encode as JPEG. A JPEG already
128/// within bounds is passed through untouched.
129fn encode(bytes: &[u8], size: u32) -> Option<Vec<u8>> {
130    use image::GenericImageView as _;
131    let img = image::load_from_memory(bytes).ok()?;
132    let (w, h) = img.dimensions();
133    if w.max(h) <= size && bytes.starts_with(&[0xFF, 0xD8]) {
134        return Some(bytes.to_vec());
135    }
136    let img = if w.max(h) > size {
137        img.thumbnail(size, size)
138    } else {
139        img
140    };
141    let mut out = Vec::new();
142    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
143        .encode_image(&img.to_rgb8())
144        .ok()?;
145    Some(out)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn requests_snap_up_to_a_served_size() {
154        assert_eq!(snap(Some(1)), 200);
155        assert_eq!(snap(Some(400)), 400);
156        assert_eq!(snap(Some(401)), 800);
157        assert_eq!(snap(Some(99_999)), 1200);
158        assert_eq!(snap(None), LARGE);
159    }
160
161    #[test]
162    fn covers_are_bounded_jpegs() {
163        use image::GenericImageView as _;
164        let png = {
165            let mut out = std::io::Cursor::new(Vec::new());
166            image::DynamicImage::new_rgba8(2400, 1200)
167                .write_to(&mut out, image::ImageFormat::Png)
168                .unwrap();
169            out.into_inner()
170        };
171        let out = encode(&png, 400).unwrap();
172        assert!(out.starts_with(&[0xFF, 0xD8]));
173        assert_eq!(
174            image::load_from_memory(&out).unwrap().dimensions(),
175            (400, 200)
176        );
177
178        let small = encode(&png, 400).unwrap();
179        assert_eq!(
180            encode(&small, 800).unwrap(),
181            small,
182            "a small JPEG passes through"
183        );
184    }
185}