1use 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
21pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
24pub(crate) const GRID: u32 = 400;
26pub(crate) const LARGE: u32 = 800;
28
29const MEMORY_ENTRIES: usize = 512;
30const 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 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 pub fn in_config_dir() -> Self {
60 Self::new(koan_core::config::config_dir().join("covers"))
61 }
62
63 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 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 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 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
113fn 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
127fn 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}