use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use axum::body::Bytes;
use koan_core::db::queries::TrackRow;
use lru::LruCache;
pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
pub(crate) const GRID: u32 = 400;
pub(crate) const LARGE: u32 = 800;
const MEMORY_ENTRIES: usize = 512;
const TRACKS_TRIED: usize = 3;
const JPEG_QUALITY: u8 = 85;
pub(crate) fn snap(size: Option<u32>) -> u32 {
let size = size.unwrap_or(LARGE);
SIZES
.into_iter()
.find(|s| *s >= size)
.unwrap_or(SIZES[SIZES.len() - 1])
}
pub struct Covers {
dir: PathBuf,
memory: Mutex<LruCache<String, Option<Bytes>>>,
}
impl Covers {
pub fn new(dir: PathBuf) -> Self {
Self {
dir,
memory: Mutex::new(LruCache::new(
NonZeroUsize::new(MEMORY_ENTRIES).expect("non-zero"),
)),
}
}
pub fn in_config_dir() -> Self {
Self::new(koan_core::config::config_dir().join("covers"))
}
pub(crate) fn cover(&self, tracks: &[TrackRow], size: u32) -> Option<Bytes> {
let sources: Vec<(PathBuf, String)> = tracks
.iter()
.filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
.take(TRACKS_TRIED)
.map(|p| {
let key = key(&p, size);
(p, key)
})
.collect();
let (_, first_key) = sources.first()?;
let first_key = first_key.clone();
if let Some(hit) = self.memory.lock().ok()?.get(&first_key).cloned() {
return hit;
}
let found = self.read_disk(&first_key).or_else(|| {
let art = sources.iter().find_map(|(p, _)| {
koan_core::index::metadata::extract_cover_art(p)
.and_then(|bytes| encode(&bytes, size))
});
self.write_disk(&first_key, art.as_deref());
Some(art.map(Bytes::from))
})?;
if let Ok(mut memory) = self.memory.lock() {
memory.put(first_key, found.clone());
}
found
}
fn read_disk(&self, key: &str) -> Option<Option<Bytes>> {
let bytes = std::fs::read(self.dir.join(key)).ok()?;
Some((!bytes.is_empty()).then(|| Bytes::from(bytes)))
}
fn write_disk(&self, key: &str, art: Option<&[u8]>) {
let _ = std::fs::create_dir_all(&self.dir);
let tmp = self.dir.join(format!("{key}.tmp"));
if std::fs::write(&tmp, art.unwrap_or_default()).is_ok() {
let _ = std::fs::rename(&tmp, self.dir.join(key));
}
}
}
fn key(path: &Path, size: u32) -> String {
let meta = std::fs::metadata(path).ok();
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs());
let len = meta.map_or(0, |m| m.len());
let digest = md5::compute(format!("{}\0{len}\0{mtime}", path.display()));
format!("{digest:x}-{size}.jpg")
}
fn encode(bytes: &[u8], size: u32) -> Option<Vec<u8>> {
use image::GenericImageView as _;
let img = image::load_from_memory(bytes).ok()?;
let (w, h) = img.dimensions();
if w.max(h) <= size && bytes.starts_with(&[0xFF, 0xD8]) {
return Some(bytes.to_vec());
}
let img = if w.max(h) > size {
img.thumbnail(size, size)
} else {
img
};
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
.encode_image(&img.to_rgb8())
.ok()?;
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_snap_up_to_a_served_size() {
assert_eq!(snap(Some(1)), 200);
assert_eq!(snap(Some(400)), 400);
assert_eq!(snap(Some(401)), 800);
assert_eq!(snap(Some(99_999)), 1200);
assert_eq!(snap(None), LARGE);
}
#[test]
fn covers_are_bounded_jpegs() {
use image::GenericImageView as _;
let png = {
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::new_rgba8(2400, 1200)
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
};
let out = encode(&png, 400).unwrap();
assert!(out.starts_with(&[0xFF, 0xD8]));
assert_eq!(
image::load_from_memory(&out).unwrap().dimensions(),
(400, 200)
);
let small = encode(&png, 400).unwrap();
assert_eq!(
encode(&small, 800).unwrap(),
small,
"a small JPEG passes through"
);
}
}