1use std::path::{Path, PathBuf};
22use std::sync::LazyLock;
23
24use axum::body::Bytes;
25
26use koan_core::db::queries::TrackRow;
27
28pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
31pub(crate) const GRID: u32 = 400;
33pub(crate) const LARGE: u32 = 800;
35
36const 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
48static 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 pub fn in_config_dir() -> Self {
69 Self::new(koan_core::config::config_dir().join("covers"))
70 }
71
72 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 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 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 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
135enum Source {
137 Image(PathBuf),
139 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
151fn 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
165fn 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
186pub(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 const SURFACE: [f32; 3] = [42.0, 42.0, 42.0];
206 const MUTED: [f32; 3] = [145.0, 145.0, 145.0];
207 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 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}