1use std::path::{Path, PathBuf};
20use std::sync::LazyLock;
21
22use axum::body::Bytes;
23
24use koan_core::db::queries::TrackRow;
25
26pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
29pub(crate) const GRID: u32 = 400;
31pub(crate) const LARGE: u32 = 800;
33
34const 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
46static 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 pub fn in_config_dir() -> Self {
67 Self::new(koan_core::config::config_dir().join("covers"))
68 }
69
70 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 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 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 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
123fn 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
137fn 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}