1use std::path::{Path, PathBuf};
28use std::sync::LazyLock;
29
30use axum::body::Bytes;
31
32use koan_core::db::queries::TrackRow;
33
34pub(crate) const SIZES: [u32; 4] = koan_core::remote::client::COVER_SIZES;
38pub(crate) const GRID: u32 = 600;
40pub(crate) const LARGE: u32 = 1200;
42pub(crate) const WARMED: [u32; 2] = [300, GRID];
46
47const TRACKS_TRIED: usize = 3;
49const JPEG_QUALITY: u8 = 85;
50
51pub(crate) fn snap(size: Option<u32>) -> u32 {
52 let size = size.unwrap_or(LARGE);
53 SIZES
54 .into_iter()
55 .find(|s| *s >= size)
56 .unwrap_or(SIZES[SIZES.len() - 1])
57}
58
59static DECODE: LazyLock<rayon::ThreadPool> = LazyLock::new(|| {
61 let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(1, 4));
62 rayon::ThreadPoolBuilder::new()
63 .num_threads(threads)
64 .thread_name(|i| format!("koan-covers-{i}"))
65 .build()
66 .expect("cover threads")
67});
68
69pub(crate) const BUDGET: u64 = 4 * 1024 * 1024 * 1024;
71const SWEEP_EVERY: u64 = 256;
73const TOUCH_AFTER: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
76const EVICTED: &str = ".evicted";
79pub(crate) const WARM_EVERY: std::time::Duration = std::time::Duration::from_secs(10 * 60);
82const TMP_LEFT: std::time::Duration = std::time::Duration::from_secs(60 * 60);
84
85pub struct Covers {
86 dir: PathBuf,
87 budget: u64,
88 writes: std::sync::atomic::AtomicU64,
89 sweeping: std::sync::Arc<std::sync::atomic::AtomicBool>,
90 evicted: parking_lot::Mutex<Option<i64>>,
94 warm: (parking_lot::Mutex<bool>, parking_lot::Condvar),
96 report: std::sync::Arc<parking_lot::Mutex<Report>>,
97 changed: std::sync::Arc<tokio::sync::watch::Sender<u64>>,
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub(crate) enum WarmState {
104 Off,
106 Running,
107 AtBudget,
109 Idle,
110}
111
112#[derive(Clone, Copy, Debug)]
114pub(crate) struct Warm {
115 pub state: WarmState,
116 pub done: usize,
118 pub total: usize,
119 pub made: usize,
121 pub started: Option<std::time::SystemTime>,
122 pub finished: Option<std::time::SystemTime>,
123}
124
125impl Warm {
126 pub(crate) fn next(&self) -> Option<std::time::SystemTime> {
128 match self.state {
129 WarmState::Idle | WarmState::AtBudget => self.finished.map(|t| t + WARM_EVERY),
130 WarmState::Off | WarmState::Running => None,
131 }
132 }
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub(crate) struct Sweep {
138 pub at: std::time::SystemTime,
139 pub removed: usize,
140 pub bytes: u64,
141}
142
143#[derive(Clone, Copy, Debug)]
144pub(crate) struct Report {
145 pub warm: Warm,
146 pub sweep: Option<Sweep>,
148}
149
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub(crate) struct SizeUsage {
153 pub size: u32,
154 pub covers: usize,
156 pub bytes: u64,
157 pub misses: usize,
159}
160
161#[derive(Clone, Debug, Default, PartialEq, Eq)]
163pub(crate) struct Usage {
164 pub bytes: u64,
165 pub budget: u64,
166 pub sizes: Vec<SizeUsage>,
167 pub unwarmed: usize,
169}
170
171impl Usage {
172 pub(crate) fn covers(&self) -> usize {
173 self.sizes.iter().map(|s| s.covers).sum()
174 }
175
176 pub(crate) fn misses(&self) -> usize {
177 self.sizes.iter().map(|s| s.misses).sum()
178 }
179}
180
181impl Covers {
182 pub fn new(dir: PathBuf) -> Self {
183 let evicted = std::fs::read_to_string(dir.join(EVICTED))
184 .ok()
185 .and_then(|s| s.trim().parse().ok());
186 Self {
187 dir,
188 budget: BUDGET,
189 writes: Default::default(),
190 sweeping: Default::default(),
191 evicted: parking_lot::Mutex::new(evicted),
192 warm: Default::default(),
193 report: std::sync::Arc::new(parking_lot::Mutex::new(Report {
194 warm: Warm {
195 state: WarmState::Off,
196 done: 0,
197 total: 0,
198 made: 0,
199 started: None,
200 finished: None,
201 },
202 sweep: None,
203 })),
204 changed: std::sync::Arc::new(tokio::sync::watch::Sender::new(0)),
205 }
206 }
207
208 pub(crate) fn report(&self) -> Report {
209 *self.report.lock()
210 }
211
212 pub(crate) fn subscribe(&self) -> tokio::sync::watch::Receiver<u64> {
214 self.changed.subscribe()
215 }
216
217 fn ring(&self) {
218 ring(&self.changed);
219 }
220
221 fn set_warm(&self, change: impl FnOnce(&mut Warm)) {
222 change(&mut self.report.lock().warm);
223 self.ring();
224 }
225
226 pub(crate) fn usage(&self, conn: &rusqlite::Connection) -> Usage {
229 let mut sizes: Vec<SizeUsage> = SIZES
230 .into_iter()
231 .map(|size| SizeUsage {
232 size,
233 ..Default::default()
234 })
235 .collect();
236 let mut bytes = 0;
237 let mut warmed: std::collections::HashMap<i64, u8> = Default::default();
239 if let Ok(read) = std::fs::read_dir(&self.dir) {
240 for entry in read.flatten() {
241 let len = entry.metadata().map_or(0, |m| m.len());
242 bytes += len;
243 let name = entry.file_name().to_string_lossy().into_owned();
244 let Some((album, size)) = parse(&name) else {
245 continue;
246 };
247 if let Some(at) = sizes.iter_mut().find(|s| s.size == size) {
248 at.covers += 1;
249 at.bytes += len;
250 at.misses += usize::from(len == 0);
251 }
252 if let (Some(album), Some(bit)) = (album, WARMED.iter().position(|s| *s == size)) {
253 *warmed.entry(album).or_default() |= 1 << bit;
254 }
255 }
256 }
257 let all = (1u8 << WARMED.len()) - 1;
258 let unwarmed = conn
259 .prepare(
260 "SELECT DISTINCT album_id FROM tracks
261 WHERE path IS NOT NULL AND album_id IS NOT NULL",
262 )
263 .and_then(|mut stmt| {
264 stmt.query_map([], |r| r.get::<_, i64>(0)).map(|ids| {
265 ids.flatten()
266 .filter(|id| warmed.get(id) != Some(&all))
267 .count()
268 })
269 })
270 .unwrap_or(0);
271 Usage {
272 bytes,
273 budget: self.budget,
274 sizes,
275 unwarmed,
276 }
277 }
278
279 pub(crate) fn clear(&self) -> (usize, u64) {
282 let (mut files, mut bytes) = (0, 0);
283 if let Ok(read) = std::fs::read_dir(&self.dir) {
284 for entry in read.flatten() {
285 if entry.file_name().to_string_lossy().starts_with('.') {
286 continue;
287 }
288 let len = entry.metadata().map_or(0, |m| m.len());
289 if std::fs::remove_file(entry.path()).is_ok() {
290 files += 1;
291 bytes += len;
292 }
293 }
294 }
295 log::info!("covers: cleared {files} covers ({} MB)", bytes >> 20);
296 self.ring();
297 self.poke();
298 (files, bytes)
299 }
300
301 pub(crate) fn warm_now(&self) -> bool {
304 if self.report().warm.state == WarmState::Off {
305 return false;
306 }
307 self.poke();
308 true
309 }
310
311 pub(crate) fn album_cover(
317 &self,
318 album: i64,
319 size: u32,
320 make: impl FnOnce() -> Option<Option<Bytes>>,
321 ) -> Option<Bytes> {
322 let key = album_key(album, size);
323 if let Some(hit) = self.read_disk(&key) {
324 return hit;
325 }
326 let art = make()?;
327 self.write_disk(&key, art.as_deref());
328 art
329 }
330
331 pub(crate) fn album_cover_kept(&self, album: i64, size: u32) -> Option<Option<Bytes>> {
335 self.read_disk(&album_key(album, size))
336 }
337
338 pub(crate) fn forget_evicted(&self, conn: &rusqlite::Connection) {
345 let mut seen = self.evicted.lock();
346 let Some(since) = *seen else {
347 if let Ok(now) = koan_core::db::queries::art_evictions_since(conn, i64::MAX) {
349 *seen = Some(now.seq);
350 self.mark(now.seq);
351 }
352 return;
353 };
354 let Ok(gone) = koan_core::db::queries::art_evictions_since(conn, since) else {
355 return;
356 };
357 if gone.seq == since && !gone.purge {
358 return;
359 }
360 if gone.purge {
361 if let Ok(read) = std::fs::read_dir(&self.dir) {
362 for entry in read.flatten() {
363 let name = entry.file_name();
364 if matches!(parse(&name.to_string_lossy()), Some((Some(_), _))) {
365 let _ = std::fs::remove_file(entry.path());
366 }
367 }
368 }
369 log::info!("covers: the library's evictions outran the cache; album covers dropped");
370 } else {
371 for (kind, id) in &gone.named {
372 if kind == "album" {
373 for size in SIZES {
374 let _ = std::fs::remove_file(self.dir.join(album_key(*id, size)));
375 }
376 }
377 }
378 }
379 *seen = Some(gone.seq);
380 self.mark(gone.seq);
381 drop(seen);
382 self.poke();
383 }
384
385 fn mark(&self, seq: i64) {
386 let _ = std::fs::create_dir_all(&self.dir);
387 let _ = std::fs::write(self.dir.join(EVICTED), seq.to_string());
388 }
389
390 fn poke(&self) {
392 *self.warm.0.lock() = true;
393 self.warm.1.notify_one();
394 }
395
396 pub fn warm(self: &std::sync::Arc<Self>, pool: std::sync::Arc<koan_core::db::pool::Pool>) {
401 let covers = self.clone();
402 let started = std::thread::Builder::new()
403 .name("koan-covers-warm".into())
404 .spawn(move || {
405 prune(&covers.dir);
406 covers.set_warm(|w| w.state = WarmState::Idle);
407 loop {
408 covers.warm_pass(&pool);
409 let mut poked = covers.warm.0.lock();
410 if !*poked {
411 covers.warm.1.wait_for(&mut poked, WARM_EVERY);
412 }
413 *poked = false;
414 }
415 });
416 if let Err(e) = started {
417 log::warn!("covers: no warm thread: {e}");
418 }
419 }
420
421 fn warm_pass(&self, pool: &koan_core::db::pool::Pool) {
422 let mut used = dir_bytes(&self.dir);
426 if used >= self.budget {
427 self.set_warm(|w| {
428 w.state = WarmState::AtBudget;
429 w.finished = Some(std::time::SystemTime::now());
430 });
431 return;
432 }
433 let albums: Vec<i64> = {
434 let Ok(db) = pool.get() else { return };
435 self.forget_evicted(&db.conn);
436 let Ok(mut stmt) = db.conn.prepare(
437 "SELECT album_id FROM tracks
438 WHERE path IS NOT NULL AND album_id IS NOT NULL
439 GROUP BY album_id ORDER BY MIN(path)",
440 ) else {
441 return;
442 };
443 stmt.query_map([], |r| r.get(0))
444 .map(|rows| rows.flatten().collect())
445 .unwrap_or_default()
446 };
447 let total = albums.len();
448 self.set_warm(|w| {
449 *w = Warm {
450 state: WarmState::Running,
451 done: 0,
452 total,
453 made: 0,
454 started: Some(std::time::SystemTime::now()),
455 finished: w.finished,
456 }
457 });
458 let mut made = 0usize;
459 let mut state = WarmState::Idle;
460 let mut finished = true;
461 for (done, album) in albums.into_iter().enumerate() {
462 let wanted: Vec<u32> = WARMED
463 .into_iter()
464 .filter(|size| !self.dir.join(album_key(album, *size)).exists())
465 .collect();
466 if wanted.is_empty() {
467 self.report.lock().warm.done = done + 1;
470 continue;
471 }
472 let tracks = match pool.get() {
473 Ok(db) => {
474 koan_core::db::queries::tracks_for_album(&db.conn, album).unwrap_or_default()
475 }
476 Err(_) => {
477 finished = false;
478 break;
479 }
480 };
481 if !readable(&tracks) {
485 continue;
486 }
487 let mut sources = self.source_arts(&tracks);
490 let mut source: Option<Vec<u8>> = None;
491 for size in wanted {
492 let art = DECODE.install(|| {
493 if let Some(bytes) = &source {
494 return encode(bytes, size);
495 }
496 sources.find_map(|bytes| {
497 let art = encode(&bytes, size)?;
498 source = Some(bytes);
499 Some(art)
500 })
501 });
502 used += art.as_ref().map_or(0, |a| a.len() as u64);
503 self.write_disk(&album_key(album, size), art.as_deref());
504 }
505 made += 1;
506 self.set_warm(|w| {
507 w.done = done + 1;
508 w.made = made;
509 });
510 if used >= self.budget {
511 log::info!("covers: the cover directory has reached its budget; warming stops");
512 state = WarmState::AtBudget;
513 break;
514 }
515 }
516 self.set_warm(|w| {
518 w.state = state;
519 if state == WarmState::Idle && finished {
520 w.done = total;
521 }
522 w.finished = Some(std::time::SystemTime::now());
523 });
524 if made > 0 {
525 log::info!("covers: {made} albums' covers made ready");
526 }
527 }
528
529 pub fn in_config_dir() -> Self {
531 Self::new(koan_core::config::config_dir().join("covers"))
532 }
533
534 pub(crate) fn cover(&self, tracks: &[TrackRow], size: u32) -> Option<Bytes> {
539 let sources: Vec<(Source, String)> = tracks
540 .iter()
541 .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
542 .take(TRACKS_TRIED)
543 .map(|p| {
544 let source = match koan_core::index::folder_art::folder_cover(&p) {
545 Some(image) => Source::Image(image),
546 None => Source::Embedded(p),
547 };
548 let key = key(source.path(), size);
549 (source, key)
550 })
551 .collect();
552 let (_, first_key) = sources.first()?;
556 if let Some(hit) = self.read_disk(first_key) {
557 return hit;
558 }
559 if !readable(tracks) {
561 return None;
562 }
563 let art = DECODE.install(|| {
564 sources.iter().find_map(|(source, _)| {
565 let bytes = match source {
566 Source::Image(p) => std::fs::read(p).ok(),
567 Source::Embedded(p) => koan_core::index::metadata::extract_cover_art(p),
568 };
569 bytes.and_then(|bytes| encode(&bytes, size))
570 })
571 });
572 self.write_disk(first_key, art.as_deref());
573 art.map(Bytes::from)
574 }
575
576 fn source_arts<'a>(&self, tracks: &'a [TrackRow]) -> impl Iterator<Item = Vec<u8>> + 'a {
580 tracks
581 .iter()
582 .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
583 .take(TRACKS_TRIED)
584 .filter_map(|p| match koan_core::index::folder_art::folder_cover(&p) {
585 Some(image) => std::fs::read(image).ok(),
586 None => koan_core::index::metadata::extract_cover_art(&p),
587 })
588 }
589
590 fn read_disk(&self, key: &str) -> Option<Option<Bytes>> {
593 use std::io::Read as _;
594 let mut file = std::fs::File::open(self.dir.join(key)).ok()?;
595 let mut bytes = Vec::new();
596 file.read_to_end(&mut bytes).ok()?;
597 let now = std::time::SystemTime::now();
598 if file
599 .metadata()
600 .and_then(|m| m.modified())
601 .is_ok_and(|t| now.duration_since(t).unwrap_or_default() > TOUCH_AFTER)
602 {
603 let _ = file.set_modified(now);
604 }
605 Some((!bytes.is_empty()).then(|| Bytes::from(bytes)))
606 }
607
608 fn write_disk(&self, key: &str, art: Option<&[u8]>) {
613 use std::sync::atomic::Ordering;
614 let _ = std::fs::create_dir_all(&self.dir);
615 let n = self.writes.fetch_add(1, Ordering::Relaxed);
616 if n.is_multiple_of(SWEEP_EVERY) && !self.sweeping.swap(true, Ordering::AcqRel) {
617 let (dir, budget, sweeping) = (self.dir.clone(), self.budget, self.sweeping.clone());
618 let (report, changed) = (self.report.clone(), self.changed.clone());
619 std::thread::spawn(move || {
620 if let Some((removed, bytes)) = sweep(&dir, budget) {
621 report.lock().sweep = Some(Sweep {
622 at: std::time::SystemTime::now(),
623 removed,
624 bytes,
625 });
626 ring(&changed);
627 }
628 sweeping.store(false, Ordering::Release);
629 });
630 }
631 let tmp = self
632 .dir
633 .join(format!("{key}.{}-{n}.tmp", std::process::id()));
634 if std::fs::write(&tmp, art.unwrap_or_default()).is_ok()
635 && std::fs::rename(&tmp, self.dir.join(key)).is_err()
636 {
637 let _ = std::fs::remove_file(&tmp);
638 }
639 }
640}
641
642fn prune(dir: &Path) {
646 let Ok(read) = std::fs::read_dir(dir) else {
647 return;
648 };
649 let (mut files, mut bytes) = (0usize, 0u64);
650 for entry in read.flatten() {
651 let name = entry.file_name().to_string_lossy().into_owned();
652 if !current(&name) {
653 let len = entry.metadata().map_or(0, |m| m.len());
654 if std::fs::remove_file(entry.path()).is_ok() {
655 files += 1;
656 bytes += len;
657 }
658 }
659 }
660 if files > 0 {
661 log::info!(
662 "covers: removed {files} covers no longer served ({} MB)",
663 bytes >> 20
664 );
665 }
666}
667
668fn ring(changed: &tokio::sync::watch::Sender<u64>) {
669 changed.send_modify(|n| *n = n.wrapping_add(1));
670}
671
672fn current(name: &str) -> bool {
675 name.starts_with('.') || name.ends_with(".tmp") || parse(name).is_some()
676}
677
678fn parse(name: &str) -> Option<(Option<i64>, u32)> {
681 let (head, size) = name.strip_suffix(".jpg")?.rsplit_once('-')?;
682 let size = size.parse().ok().filter(|size| SIZES.contains(size))?;
683 match head.strip_prefix("album-") {
684 Some(id) => Some((Some(id.parse().ok()?), size)),
685 None => (head.len() == 32 && head.bytes().all(|b| b.is_ascii_hexdigit()))
686 .then_some((None, size)),
687 }
688}
689
690fn dir_bytes(dir: &Path) -> u64 {
692 std::fs::read_dir(dir)
693 .map(|read| {
694 read.flatten()
695 .filter_map(|e| e.metadata().ok())
696 .map(|m| m.len())
697 .sum()
698 })
699 .unwrap_or(0)
700}
701
702pub(crate) fn readable(tracks: &[TrackRow]) -> bool {
706 tracks
707 .iter()
708 .filter_map(crate::subsonic::track_file_path)
709 .take(TRACKS_TRIED)
710 .any(|p| Path::new(&p).exists())
711}
712
713fn sweep(dir: &Path, budget: u64) -> Option<(usize, u64)> {
717 let now = std::time::SystemTime::now();
718 let read = std::fs::read_dir(dir).ok()?;
719 let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = Vec::new();
720 for entry in read.flatten() {
721 let Ok(meta) = entry.metadata() else { continue };
722 let modified = meta.modified().unwrap_or(now);
723 let path = entry.path();
724 if entry.file_name().to_string_lossy().starts_with('.') {
725 continue;
726 }
727 if path.extension().is_some_and(|e| e == "tmp") {
728 if now.duration_since(modified).unwrap_or_default() > TMP_LEFT {
729 let _ = std::fs::remove_file(&path);
730 }
731 continue;
732 }
733 kept.push((modified, meta.len(), path));
734 }
735 let mut total: u64 = kept.iter().map(|(_, len, _)| len).sum();
736 if total <= budget {
737 return None;
738 }
739 kept.sort_by_key(|(modified, _, _)| *modified);
740 let target = budget - budget / 10;
741 let (mut removed, mut bytes) = (0, 0);
742 for (_, len, path) in kept {
743 if total <= target {
744 break;
745 }
746 if std::fs::remove_file(&path).is_ok() {
747 total -= len;
748 removed += 1;
749 bytes += len;
750 }
751 }
752 Some((removed, bytes))
753}
754
755enum Source {
757 Image(PathBuf),
759 Embedded(PathBuf),
761}
762
763impl Source {
764 fn path(&self) -> &Path {
765 match self {
766 Self::Image(p) | Self::Embedded(p) => p,
767 }
768 }
769}
770
771fn key(path: &Path, size: u32) -> String {
774 let meta = std::fs::metadata(path).ok();
775 let mtime = meta
776 .as_ref()
777 .and_then(|m| m.modified().ok())
778 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
779 .map_or(0, |d| d.as_secs());
780 let len = meta.map_or(0, |m| m.len());
781 let digest = md5::compute(format!("{}\0{len}\0{mtime}", path.display()));
782 format!("{digest:x}-{size}.jpg")
783}
784
785fn album_key(album: i64, size: u32) -> String {
787 format!("album-{album}-{size}.jpg")
788}
789
790fn encode(bytes: &[u8], size: u32) -> Option<Vec<u8>> {
793 use image::GenericImageView as _;
794 let img = image::load_from_memory(bytes).ok()?;
795 let (w, h) = img.dimensions();
796 if w.max(h) <= size && bytes.starts_with(&[0xFF, 0xD8]) {
797 return Some(bytes.to_vec());
798 }
799 let img = if w.max(h) > size {
800 img.thumbnail(size, size)
801 } else {
802 img
803 };
804 let mut out = Vec::new();
805 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
806 .encode_image(&img.to_rgb8())
807 .ok()?;
808 Some(out)
809}
810
811pub(crate) fn placeholder(size: u32) -> Bytes {
817 static DRAWN: [std::sync::OnceLock<Bytes>; SIZES.len()] =
818 [const { std::sync::OnceLock::new() }; SIZES.len()];
819 let size = snap(Some(size));
820 let slot = SIZES
821 .iter()
822 .position(|s| *s == size)
823 .expect("snap answers one of SIZES");
824 DRAWN[slot].get_or_init(|| draw_placeholder(size)).clone()
825}
826
827fn draw_placeholder(size: u32) -> Bytes {
828 const SURFACE: [f32; 3] = [42.0, 42.0, 42.0];
831 const MUTED: [f32; 3] = [145.0, 145.0, 145.0];
832 let side = size as f32;
837 let scale = side * 0.48 * 0.6 / 19.0;
838 let points: Vec<(f32, f32)> = crate::icon::ENSO
839 .strokes
840 .split(['M', 'L'])
841 .filter_map(|p| {
842 let (x, y) = p.split_once(' ')?;
843 let (x, y): (f32, f32) = (x.trim().parse().ok()?, y.trim().parse().ok()?);
844 Some((
845 side / 2.0 + (x - 12.0) * scale,
846 side / 2.0 + (y - 12.0) * scale,
847 ))
848 })
849 .collect();
850 let radius = side * 0.045 / 2.0;
851 let mut img = image::RgbImage::from_pixel(size, size, image::Rgb(SURFACE.map(|c| c as u8)));
852 let (mut lo, mut hi) = (side, 0.0f32);
854 for &(x, y) in &points {
855 lo = lo.min(x.min(y) - radius - 1.0);
856 hi = hi.max(x.max(y) + radius + 1.0);
857 }
858 let span = (lo.max(0.0) as u32)..(hi.min(side) as u32);
859 for y in span.clone() {
860 for x in span.clone() {
861 let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
862 let distance = points
863 .windows(2)
864 .map(|w| {
865 let ((ax, ay), (bx, by)) = (w[0], w[1]);
866 let (dx, dy) = (bx - ax, by - ay);
867 let t =
868 (((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
869 (px - ax - t * dx).hypot(py - ay - t * dy)
870 })
871 .fold(f32::INFINITY, f32::min);
872 let cover = (radius + 0.5 - distance).clamp(0.0, 1.0);
873 if cover > 0.0 {
874 let mix = |i: usize| (SURFACE[i] + (MUTED[i] - SURFACE[i]) * cover).round() as u8;
875 img.put_pixel(x, y, image::Rgb([mix(0), mix(1), mix(2)]));
876 }
877 }
878 }
879 let mut out = std::io::Cursor::new(Vec::new());
880 img.write_to(&mut out, image::ImageFormat::Png)
881 .expect("PNG encodes in memory");
882 Bytes::from(out.into_inner())
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888
889 #[test]
892 fn covers_no_longer_served_are_pruned() {
893 let dir = tempfile::tempdir().unwrap();
894 let hash = "0123456789abcdef0123456789abcdef";
895 let keep = [
896 "album-12-600.jpg".to_string(),
897 format!("{hash}-300.jpg"),
898 ".evicted".to_string(),
899 "album-1-600.jpg.42-7.tmp".to_string(),
900 ];
901 let go = [
902 format!("{hash}-800.jpg"),
903 "album-12-400.jpg".to_string(),
904 "notes.txt".to_string(),
905 ];
906 for name in keep.iter().chain(&go) {
907 std::fs::write(dir.path().join(name), b"x").unwrap();
908 }
909 prune(dir.path());
910 for name in &keep {
911 assert!(dir.path().join(name).exists(), "{name} kept");
912 }
913 for name in &go {
914 assert!(!dir.path().join(name).exists(), "{name} removed");
915 }
916 }
917
918 #[test]
921 fn an_album_is_read_from_its_files_once() {
922 let dir = tempfile::tempdir().unwrap();
923 let covers = Covers::new(dir.path().to_path_buf());
924 let made = std::cell::Cell::new(0);
925 let make = |art: Option<Bytes>| {
926 made.set(made.get() + 1);
927 Some(art)
928 };
929 let jpeg = Bytes::from_static(b"jpeg");
930 assert_eq!(
931 covers.album_cover(1, GRID, || make(Some(jpeg.clone()))),
932 Some(jpeg.clone())
933 );
934 assert_eq!(covers.album_cover(1, GRID, || make(None)), Some(jpeg));
935 assert_eq!(covers.album_cover(2, GRID, || make(None)), None);
936 assert_eq!(
937 covers.album_cover(2, GRID, || make(Some(Bytes::new()))),
938 None
939 );
940 assert_eq!(made.get(), 2);
941 assert_eq!(covers.album_cover(3, GRID, || None), None);
942 assert!(!dir.path().join(album_key(3, GRID)).exists());
943 }
944
945 #[test]
948 fn a_new_database_drops_every_album() {
949 let dir = tempfile::tempdir().unwrap();
950 std::fs::create_dir_all(dir.path()).unwrap();
951 std::fs::write(dir.path().join(EVICTED), "500").unwrap();
952 let conn = rusqlite::Connection::open_in_memory().unwrap();
953 conn.execute_batch(
954 "CREATE TABLE art_evictions (seq INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, id INTEGER);
955 INSERT INTO art_evictions (kind, id) VALUES ('track', 9);",
956 )
957 .unwrap();
958 let covers = Covers::new(dir.path().to_path_buf());
959 covers.album_cover(1, GRID, || Some(Some(Bytes::from_static(b"x"))));
960 covers.forget_evicted(&conn);
961 assert!(!dir.path().join(album_key(1, GRID)).exists());
962 assert_eq!(
963 std::fs::read_to_string(dir.path().join(EVICTED)).unwrap(),
964 "1"
965 );
966 }
967
968 #[test]
971 fn evicted_albums_are_read_again() {
972 let dir = tempfile::tempdir().unwrap();
973 let conn = rusqlite::Connection::open_in_memory().unwrap();
974 conn.execute_batch(
975 "CREATE TABLE art_evictions (seq INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, id INTEGER);
976 INSERT INTO art_evictions (kind, id) VALUES ('album', 1);",
977 )
978 .unwrap();
979 let covers = Covers::new(dir.path().to_path_buf());
980 let keep = |album| covers.album_cover(album, GRID, || Some(Some(Bytes::from_static(b"x"))));
981 keep(1);
982 keep(2);
983 covers.forget_evicted(&conn);
985 assert!(dir.path().join(album_key(1, GRID)).exists());
986 conn.execute_batch(
987 "INSERT INTO art_evictions (kind, id) VALUES ('album', 2), ('track', 1);",
988 )
989 .unwrap();
990 covers.forget_evicted(&conn);
991 assert!(dir.path().join(album_key(1, GRID)).exists());
992 assert!(!dir.path().join(album_key(2, GRID)).exists());
993 assert_eq!(
995 Covers::new(dir.path().to_path_buf())
996 .evicted
997 .lock()
998 .unwrap(),
999 3
1000 );
1001 }
1002
1003 #[test]
1006 fn usage_counts_sizes_and_records_left_to_warm_and_a_clear_empties_it() {
1007 let dir = tempfile::tempdir().unwrap();
1008 let conn = rusqlite::Connection::open_in_memory().unwrap();
1009 conn.execute_batch(
1010 "CREATE TABLE tracks (id INTEGER PRIMARY KEY, album_id INTEGER, path TEXT);
1011 INSERT INTO tracks (album_id, path) VALUES (1, '/a'), (2, '/b'), (3, NULL);",
1012 )
1013 .unwrap();
1014 let covers = Covers::new(dir.path().to_path_buf());
1015 let hash = "0123456789abcdef0123456789abcdef";
1016 for (name, len) in [
1017 ("album-1-300.jpg", 100),
1018 ("album-1-600.jpg", 0),
1019 ("album-2-600.jpg", 400),
1020 (&format!("{hash}-1200.jpg") as &str, 1000),
1021 (".evicted", 2),
1022 ] {
1023 std::fs::write(dir.path().join(name), vec![0u8; len]).unwrap();
1024 }
1025 let u = covers.usage(&conn);
1026 assert_eq!(u.bytes, 1502);
1027 assert_eq!(u.budget, BUDGET);
1028 let at = |size| *u.sizes.iter().find(|s| s.size == size).unwrap();
1029 assert_eq!(at(150).covers, 0);
1030 assert_eq!((at(300).covers, at(300).bytes, at(300).misses), (1, 100, 0));
1031 assert_eq!((at(600).covers, at(600).bytes, at(600).misses), (2, 400, 1));
1032 assert_eq!(at(1200).covers, 1);
1033 assert_eq!((u.covers(), u.misses()), (4, 1));
1034 assert_eq!(u.unwarmed, 1, "record 2 lacks its 300; 3 has no files");
1035
1036 let changed = covers.subscribe();
1037 assert_eq!(covers.clear(), (4, 1500));
1038 assert!(changed.has_changed().unwrap());
1039 let left: Vec<_> = std::fs::read_dir(dir.path())
1040 .unwrap()
1041 .map(|e| e.unwrap().file_name())
1042 .collect();
1043 assert_eq!(left, [".evicted"]);
1044 assert_eq!(covers.usage(&conn).unwarmed, 2);
1045 }
1046
1047 #[test]
1050 fn the_warm_reports_its_pass() {
1051 let dir = tempfile::tempdir().unwrap();
1052 let covers = Covers::new(dir.path().join("covers"));
1053 assert_eq!(covers.report().warm.state, WarmState::Off);
1054 assert!(!covers.warm_now());
1055 let db = dir.path().join("koan.db");
1056 koan_core::db::connection::Database::open(&db).unwrap();
1057 let pool = koan_core::db::pool::Pool::new(db);
1058 covers.warm_pass(&pool);
1059 let warm = covers.report().warm;
1060 assert_eq!(warm.state, WarmState::Idle);
1061 assert_eq!((warm.done, warm.total, warm.made), (0, 0, 0));
1062 assert!(warm.started.is_some() && warm.finished.is_some());
1063 assert_eq!(warm.next(), Some(warm.finished.unwrap() + WARM_EVERY));
1064 }
1065
1066 #[test]
1067 fn requests_snap_up_to_a_served_size() {
1068 assert_eq!(snap(Some(1)), 150);
1069 assert_eq!(snap(Some(300)), 300);
1070 assert_eq!(snap(Some(301)), 600);
1071 assert_eq!(snap(Some(1000)), 1200);
1072 assert_eq!(snap(Some(99_999)), 1200);
1073 assert_eq!(snap(None), LARGE);
1074 }
1075
1076 #[test]
1077 fn the_enso_is_one_absolute_polyline() {
1078 let strokes = crate::icon::ENSO.strokes;
1079 assert!(strokes.starts_with('M'));
1080 assert_eq!(strokes.matches('M').count(), 1);
1081 assert!(
1082 strokes
1083 .chars()
1084 .all(|c| matches!(c, 'M' | 'L' | ' ' | '.' | '-' | '0'..='9'))
1085 );
1086 }
1087
1088 #[test]
1089 fn the_placeholder_is_the_size_asked_with_the_mark_inside_the_inset() {
1090 let png = placeholder(300);
1091 let img = image::load_from_memory(&png).unwrap().to_rgb8();
1092 assert_eq!(img.dimensions(), (300, 300));
1093 let surface = image::Rgb([42, 42, 42]);
1094 assert_eq!(*img.get_pixel(0, 0), surface);
1095 assert_eq!(*img.get_pixel(150, 150), surface, "the ring is open");
1096 let inked: Vec<_> = img
1097 .enumerate_pixels()
1098 .filter(|(_, _, p)| **p != surface)
1099 .collect();
1100 assert!(!inked.is_empty());
1101 let inset = (300.0 * 0.26) as u32;
1102 assert!(
1103 inked
1104 .iter()
1105 .all(|(x, y, _)| (inset..300 - inset).contains(x)
1106 && (inset..300 - inset).contains(y))
1107 );
1108 }
1109
1110 #[test]
1111 fn writers_racing_on_one_cover_leave_it_whole() {
1112 let dir = tempfile::tempdir().unwrap();
1113 let covers = Covers::new(dir.path().to_path_buf());
1114 let arts: Vec<Vec<u8>> = (0..8u8).map(|i| vec![i; 256 * 1024]).collect();
1115 for _ in 0..20 {
1116 std::thread::scope(|s| {
1117 for art in &arts {
1118 s.spawn(|| covers.write_disk("k-400.jpg", Some(art)));
1119 }
1120 });
1121 let got = std::fs::read(dir.path().join("k-400.jpg")).unwrap();
1122 assert!(arts.contains(&got), "a torn write of {} bytes", got.len());
1123 }
1124 let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
1125 assert_eq!(left.len(), 1, "no temporary files left behind");
1126 }
1127
1128 #[test]
1129 fn the_directory_is_held_to_its_budget_oldest_read_first() {
1130 let dir = tempfile::tempdir().unwrap();
1131 let day = std::time::Duration::from_secs(24 * 60 * 60);
1132 let now = std::time::SystemTime::now();
1133 let put = |name: &str, len: usize, age: u32| {
1134 let path = dir.path().join(name);
1135 std::fs::write(&path, vec![0u8; len]).unwrap();
1136 std::fs::File::options()
1137 .write(true)
1138 .open(&path)
1139 .unwrap()
1140 .set_modified(now - day * age)
1141 .unwrap();
1142 };
1143 for i in 0..10u32 {
1144 put(&format!("c{i}-400.jpg"), 1000, 10 - i);
1145 }
1146 put("c0-400.jpg.1-2.tmp", 10, 2);
1147 put("c9-400.jpg.1-3.tmp", 10, 0);
1148 assert_eq!(sweep(dir.path(), 5600), Some((5, 5000)));
1149 let mut left: Vec<String> = std::fs::read_dir(dir.path())
1150 .unwrap()
1151 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1152 .collect();
1153 left.sort();
1154 assert_eq!(
1155 left,
1156 [
1157 "c5-400.jpg",
1158 "c6-400.jpg",
1159 "c7-400.jpg",
1160 "c8-400.jpg",
1161 "c9-400.jpg",
1162 "c9-400.jpg.1-3.tmp"
1163 ],
1164 "the five read longest ago go, and the abandoned temporary file"
1165 );
1166 }
1167
1168 #[test]
1169 fn covers_are_bounded_jpegs() {
1170 use image::GenericImageView as _;
1171 let png = {
1172 let mut out = std::io::Cursor::new(Vec::new());
1173 image::DynamicImage::new_rgba8(2400, 1200)
1174 .write_to(&mut out, image::ImageFormat::Png)
1175 .unwrap();
1176 out.into_inner()
1177 };
1178 let out = encode(&png, 400).unwrap();
1179 assert!(out.starts_with(&[0xFF, 0xD8]));
1180 assert_eq!(
1181 image::load_from_memory(&out).unwrap().dimensions(),
1182 (400, 200)
1183 );
1184
1185 let small = encode(&png, 400).unwrap();
1186 assert_eq!(
1187 encode(&small, 800).unwrap(),
1188 small,
1189 "a small JPEG passes through"
1190 );
1191 }
1192}