1use std::path::{Path, PathBuf};
24use std::sync::LazyLock;
25
26use axum::body::Bytes;
27
28use koan_core::db::queries::TrackRow;
29
30pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
33pub(crate) const GRID: u32 = 400;
35pub(crate) const LARGE: u32 = 800;
37
38const TRACKS_TRIED: usize = 3;
40const JPEG_QUALITY: u8 = 85;
41
42pub(crate) fn snap(size: Option<u32>) -> u32 {
43 let size = size.unwrap_or(LARGE);
44 SIZES
45 .into_iter()
46 .find(|s| *s >= size)
47 .unwrap_or(SIZES[SIZES.len() - 1])
48}
49
50static DECODE: LazyLock<rayon::ThreadPool> = LazyLock::new(|| {
52 let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(1, 4));
53 rayon::ThreadPoolBuilder::new()
54 .num_threads(threads)
55 .thread_name(|i| format!("koan-covers-{i}"))
56 .build()
57 .expect("cover threads")
58});
59
60const BUDGET: u64 = 1024 * 1024 * 1024;
62const SWEEP_EVERY: u64 = 256;
64const TOUCH_AFTER: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
67const TMP_LEFT: std::time::Duration = std::time::Duration::from_secs(60 * 60);
69
70pub struct Covers {
71 dir: PathBuf,
72 budget: u64,
73 writes: std::sync::atomic::AtomicU64,
74 sweeping: std::sync::Arc<std::sync::atomic::AtomicBool>,
75}
76
77impl Covers {
78 pub fn new(dir: PathBuf) -> Self {
79 Self {
80 dir,
81 budget: BUDGET,
82 writes: Default::default(),
83 sweeping: Default::default(),
84 }
85 }
86
87 pub fn in_config_dir() -> Self {
89 Self::new(koan_core::config::config_dir().join("covers"))
90 }
91
92 pub(crate) fn cover(&self, tracks: &[TrackRow], size: u32) -> Option<Bytes> {
97 let sources: Vec<(Source, String)> = tracks
98 .iter()
99 .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
100 .take(TRACKS_TRIED)
101 .map(|p| {
102 let source = match koan_core::index::folder_art::folder_cover(&p) {
103 Some(image) => Source::Image(image),
104 None => Source::Embedded(p),
105 };
106 let key = key(source.path(), size);
107 (source, key)
108 })
109 .collect();
110 let (_, first_key) = sources.first()?;
114 if let Some(hit) = self.read_disk(first_key) {
115 return hit;
116 }
117 let art = DECODE.install(|| {
118 sources.iter().find_map(|(source, _)| {
119 let bytes = match source {
120 Source::Image(p) => std::fs::read(p).ok(),
121 Source::Embedded(p) => koan_core::index::metadata::extract_cover_art(p),
122 };
123 bytes.and_then(|bytes| encode(&bytes, size))
124 })
125 });
126 self.write_disk(first_key, art.as_deref());
127 art.map(Bytes::from)
128 }
129
130 fn read_disk(&self, key: &str) -> Option<Option<Bytes>> {
133 use std::io::Read as _;
134 let mut file = std::fs::File::open(self.dir.join(key)).ok()?;
135 let mut bytes = Vec::new();
136 file.read_to_end(&mut bytes).ok()?;
137 let now = std::time::SystemTime::now();
138 if file
139 .metadata()
140 .and_then(|m| m.modified())
141 .is_ok_and(|t| now.duration_since(t).unwrap_or_default() > TOUCH_AFTER)
142 {
143 let _ = file.set_modified(now);
144 }
145 Some((!bytes.is_empty()).then(|| Bytes::from(bytes)))
146 }
147
148 fn write_disk(&self, key: &str, art: Option<&[u8]>) {
153 use std::sync::atomic::Ordering;
154 let _ = std::fs::create_dir_all(&self.dir);
155 let n = self.writes.fetch_add(1, Ordering::Relaxed);
156 if n.is_multiple_of(SWEEP_EVERY) && !self.sweeping.swap(true, Ordering::AcqRel) {
157 let (dir, budget, sweeping) = (self.dir.clone(), self.budget, self.sweeping.clone());
158 std::thread::spawn(move || {
159 sweep(&dir, budget);
160 sweeping.store(false, Ordering::Release);
161 });
162 }
163 let tmp = self
164 .dir
165 .join(format!("{key}.{}-{n}.tmp", std::process::id()));
166 if std::fs::write(&tmp, art.unwrap_or_default()).is_ok()
167 && std::fs::rename(&tmp, self.dir.join(key)).is_err()
168 {
169 let _ = std::fs::remove_file(&tmp);
170 }
171 }
172}
173
174fn sweep(dir: &Path, budget: u64) {
177 let now = std::time::SystemTime::now();
178 let Ok(read) = std::fs::read_dir(dir) else {
179 return;
180 };
181 let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = Vec::new();
182 for entry in read.flatten() {
183 let Ok(meta) = entry.metadata() else { continue };
184 let modified = meta.modified().unwrap_or(now);
185 let path = entry.path();
186 if path.extension().is_some_and(|e| e == "tmp") {
187 if now.duration_since(modified).unwrap_or_default() > TMP_LEFT {
188 let _ = std::fs::remove_file(&path);
189 }
190 continue;
191 }
192 kept.push((modified, meta.len(), path));
193 }
194 let mut total: u64 = kept.iter().map(|(_, len, _)| len).sum();
195 if total <= budget {
196 return;
197 }
198 kept.sort_by_key(|(modified, _, _)| *modified);
199 let target = budget - budget / 10;
200 for (_, len, path) in kept {
201 if total <= target {
202 break;
203 }
204 if std::fs::remove_file(&path).is_ok() {
205 total -= len;
206 }
207 }
208}
209
210enum Source {
212 Image(PathBuf),
214 Embedded(PathBuf),
216}
217
218impl Source {
219 fn path(&self) -> &Path {
220 match self {
221 Self::Image(p) | Self::Embedded(p) => p,
222 }
223 }
224}
225
226fn key(path: &Path, size: u32) -> String {
229 let meta = std::fs::metadata(path).ok();
230 let mtime = meta
231 .as_ref()
232 .and_then(|m| m.modified().ok())
233 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
234 .map_or(0, |d| d.as_secs());
235 let len = meta.map_or(0, |m| m.len());
236 let digest = md5::compute(format!("{}\0{len}\0{mtime}", path.display()));
237 format!("{digest:x}-{size}.jpg")
238}
239
240fn encode(bytes: &[u8], size: u32) -> Option<Vec<u8>> {
243 use image::GenericImageView as _;
244 let img = image::load_from_memory(bytes).ok()?;
245 let (w, h) = img.dimensions();
246 if w.max(h) <= size && bytes.starts_with(&[0xFF, 0xD8]) {
247 return Some(bytes.to_vec());
248 }
249 let img = if w.max(h) > size {
250 img.thumbnail(size, size)
251 } else {
252 img
253 };
254 let mut out = Vec::new();
255 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
256 .encode_image(&img.to_rgb8())
257 .ok()?;
258 Some(out)
259}
260
261pub(crate) fn placeholder(size: u32) -> Bytes {
267 static DRAWN: [std::sync::OnceLock<Bytes>; SIZES.len()] =
268 [const { std::sync::OnceLock::new() }; SIZES.len()];
269 let size = snap(Some(size));
270 let slot = SIZES
271 .iter()
272 .position(|s| *s == size)
273 .expect("snap answers one of SIZES");
274 DRAWN[slot].get_or_init(|| draw_placeholder(size)).clone()
275}
276
277fn draw_placeholder(size: u32) -> Bytes {
278 const SURFACE: [f32; 3] = [42.0, 42.0, 42.0];
281 const MUTED: [f32; 3] = [145.0, 145.0, 145.0];
282 let side = size as f32;
287 let scale = side * 0.48 * 0.6 / 19.0;
288 let points: Vec<(f32, f32)> = crate::icon::ENSO
289 .strokes
290 .split(['M', 'L'])
291 .filter_map(|p| {
292 let (x, y) = p.split_once(' ')?;
293 let (x, y): (f32, f32) = (x.trim().parse().ok()?, y.trim().parse().ok()?);
294 Some((
295 side / 2.0 + (x - 12.0) * scale,
296 side / 2.0 + (y - 12.0) * scale,
297 ))
298 })
299 .collect();
300 let radius = side * 0.045 / 2.0;
301 let mut img = image::RgbImage::from_pixel(size, size, image::Rgb(SURFACE.map(|c| c as u8)));
302 let (mut lo, mut hi) = (side, 0.0f32);
304 for &(x, y) in &points {
305 lo = lo.min(x.min(y) - radius - 1.0);
306 hi = hi.max(x.max(y) + radius + 1.0);
307 }
308 let span = (lo.max(0.0) as u32)..(hi.min(side) as u32);
309 for y in span.clone() {
310 for x in span.clone() {
311 let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
312 let distance = points
313 .windows(2)
314 .map(|w| {
315 let ((ax, ay), (bx, by)) = (w[0], w[1]);
316 let (dx, dy) = (bx - ax, by - ay);
317 let t =
318 (((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
319 (px - ax - t * dx).hypot(py - ay - t * dy)
320 })
321 .fold(f32::INFINITY, f32::min);
322 let cover = (radius + 0.5 - distance).clamp(0.0, 1.0);
323 if cover > 0.0 {
324 let mix = |i: usize| (SURFACE[i] + (MUTED[i] - SURFACE[i]) * cover).round() as u8;
325 img.put_pixel(x, y, image::Rgb([mix(0), mix(1), mix(2)]));
326 }
327 }
328 }
329 let mut out = std::io::Cursor::new(Vec::new());
330 img.write_to(&mut out, image::ImageFormat::Png)
331 .expect("PNG encodes in memory");
332 Bytes::from(out.into_inner())
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn requests_snap_up_to_a_served_size() {
341 assert_eq!(snap(Some(1)), 200);
342 assert_eq!(snap(Some(400)), 400);
343 assert_eq!(snap(Some(401)), 800);
344 assert_eq!(snap(Some(99_999)), 1200);
345 assert_eq!(snap(None), LARGE);
346 }
347
348 #[test]
349 fn the_enso_is_one_absolute_polyline() {
350 let strokes = crate::icon::ENSO.strokes;
351 assert!(strokes.starts_with('M'));
352 assert_eq!(strokes.matches('M').count(), 1);
353 assert!(
354 strokes
355 .chars()
356 .all(|c| matches!(c, 'M' | 'L' | ' ' | '.' | '-' | '0'..='9'))
357 );
358 }
359
360 #[test]
361 fn the_placeholder_is_the_size_asked_with_the_mark_inside_the_inset() {
362 let png = placeholder(300);
363 let img = image::load_from_memory(&png).unwrap().to_rgb8();
364 assert_eq!(img.dimensions(), (400, 400));
365 let surface = image::Rgb([42, 42, 42]);
366 assert_eq!(*img.get_pixel(0, 0), surface);
367 assert_eq!(*img.get_pixel(200, 200), surface, "the ring is open");
368 let inked: Vec<_> = img
369 .enumerate_pixels()
370 .filter(|(_, _, p)| **p != surface)
371 .collect();
372 assert!(!inked.is_empty());
373 let inset = (400.0 * 0.26) as u32;
374 assert!(
375 inked
376 .iter()
377 .all(|(x, y, _)| (inset..400 - inset).contains(x)
378 && (inset..400 - inset).contains(y))
379 );
380 }
381
382 #[test]
383 fn writers_racing_on_one_cover_leave_it_whole() {
384 let dir = tempfile::tempdir().unwrap();
385 let covers = Covers::new(dir.path().to_path_buf());
386 let arts: Vec<Vec<u8>> = (0..8u8).map(|i| vec![i; 256 * 1024]).collect();
387 for _ in 0..20 {
388 std::thread::scope(|s| {
389 for art in &arts {
390 s.spawn(|| covers.write_disk("k-400.jpg", Some(art)));
391 }
392 });
393 let got = std::fs::read(dir.path().join("k-400.jpg")).unwrap();
394 assert!(arts.contains(&got), "a torn write of {} bytes", got.len());
395 }
396 let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
397 assert_eq!(left.len(), 1, "no temporary files left behind");
398 }
399
400 #[test]
401 fn the_directory_is_held_to_its_budget_oldest_read_first() {
402 let dir = tempfile::tempdir().unwrap();
403 let day = std::time::Duration::from_secs(24 * 60 * 60);
404 let now = std::time::SystemTime::now();
405 let put = |name: &str, len: usize, age: u32| {
406 let path = dir.path().join(name);
407 std::fs::write(&path, vec![0u8; len]).unwrap();
408 std::fs::File::options()
409 .write(true)
410 .open(&path)
411 .unwrap()
412 .set_modified(now - day * age)
413 .unwrap();
414 };
415 for i in 0..10u32 {
416 put(&format!("c{i}-400.jpg"), 1000, 10 - i);
417 }
418 put("c0-400.jpg.1-2.tmp", 10, 2);
419 put("c9-400.jpg.1-3.tmp", 10, 0);
420 sweep(dir.path(), 5600);
421 let mut left: Vec<String> = std::fs::read_dir(dir.path())
422 .unwrap()
423 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
424 .collect();
425 left.sort();
426 assert_eq!(
427 left,
428 [
429 "c5-400.jpg",
430 "c6-400.jpg",
431 "c7-400.jpg",
432 "c8-400.jpg",
433 "c9-400.jpg",
434 "c9-400.jpg.1-3.tmp"
435 ],
436 "the five read longest ago go, and the abandoned temporary file"
437 );
438 }
439
440 #[test]
441 fn covers_are_bounded_jpegs() {
442 use image::GenericImageView as _;
443 let png = {
444 let mut out = std::io::Cursor::new(Vec::new());
445 image::DynamicImage::new_rgba8(2400, 1200)
446 .write_to(&mut out, image::ImageFormat::Png)
447 .unwrap();
448 out.into_inner()
449 };
450 let out = encode(&png, 400).unwrap();
451 assert!(out.starts_with(&[0xFF, 0xD8]));
452 assert_eq!(
453 image::load_from_memory(&out).unwrap().dimensions(),
454 (400, 200)
455 );
456
457 let small = encode(&png, 400).unwrap();
458 assert_eq!(
459 encode(&small, 800).unwrap(),
460 small,
461 "a small JPEG passes through"
462 );
463 }
464}