koan-server 0.60.3

GraphQL, Subsonic REST, and MCP server for koan music player.
Documentation
//! Album covers for the web UI, share pages and Subsonic: an image beside the
//! tracks or the art embedded in them, resized to one of a few sizes, encoded
//! as JPEG, and kept.
//!
//! Reading embedded art means opening the audio file and parsing its tags, and
//! the art itself is often megabytes; doing that per tile made an albums grid
//! pull tens of megabytes and seconds of server time. Resized covers are kept
//! on disk under the config directory, keyed by the source file's path, size
//! and mtime so a re-tag or a replaced image is a new key. An album with no
//! art is remembered too, so a grid of them does not reopen every file on
//! every visit.
//!
//! Nothing is held in memory: the kernel's page cache keeps hot files for
//! free and gives the memory back under pressure, where a cache of our own
//! would count against the process for as long as it runs. Decoding is the
//! expensive part — a full-size cover is tens of megabytes of pixels — so it
//! runs on a few threads of its own. A grid opening asks for hundreds of covers
//! at once; on the blocking pool each request would decode on its own thread,
//! and the allocator keeps what every one of those threads used.

use std::path::{Path, PathBuf};
use std::sync::LazyLock;

use axum::body::Bytes;

use koan_core::db::queries::TrackRow;

/// The sizes a cover is served at. A request is rounded up to one of these,
/// which bounds what the cache can hold per album.
pub(crate) const SIZES: [u32; 4] = [200, 400, 800, 1200];
/// Grid tiles, at 2x.
pub(crate) const GRID: u32 = 400;
/// Headers, the player, and link previews.
pub(crate) const LARGE: u32 = 800;

/// How many of an album's tracks are opened looking for art before giving up.
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])
}

/// The threads covers are decoded and resized on.
static DECODE: LazyLock<rayon::ThreadPool> = LazyLock::new(|| {
    let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(1, 4));
    rayon::ThreadPoolBuilder::new()
        .num_threads(threads)
        .thread_name(|i| format!("koan-covers-{i}"))
        .build()
        .expect("cover threads")
});

pub struct Covers {
    dir: PathBuf,
}

impl Covers {
    pub fn new(dir: PathBuf) -> Self {
        Self { dir }
    }

    /// Covers kept under koan's config directory.
    pub fn in_config_dir() -> Self {
        Self::new(koan_core::config::config_dir().join("covers"))
    }

    /// A cover from the first of `tracks` that has art, at `size` (one of
    /// `SIZES`), as JPEG. An image beside a track comes before the art
    /// embedded in it (see `koan_core::index::folder_art`). Blocking: call it
    /// off the async workers.
    pub(crate) fn cover(&self, tracks: &[TrackRow], size: u32) -> Option<Bytes> {
        let sources: Vec<(Source, String)> = tracks
            .iter()
            .filter_map(|t| crate::subsonic::track_file_path(t).map(PathBuf::from))
            .take(TRACKS_TRIED)
            .map(|p| {
                let source = match koan_core::index::folder_art::folder_cover(&p) {
                    Some(image) => Source::Image(image),
                    None => Source::Embedded(p),
                };
                let key = key(source.path(), size);
                (source, key)
            })
            .collect();
        // Keyed on the first candidate: that is the file whose art is shown
        // whenever it has any. An image added beside it is a new key, so a
        // remembered miss does not outlive it.
        let (_, first_key) = sources.first()?;
        if let Some(hit) = self.read_disk(first_key) {
            return hit;
        }
        let art = DECODE.install(|| {
            sources.iter().find_map(|(source, _)| {
                let bytes = match source {
                    Source::Image(p) => std::fs::read(p).ok(),
                    Source::Embedded(p) => koan_core::index::metadata::extract_cover_art(p),
                };
                bytes.and_then(|bytes| encode(&bytes, size))
            })
        });
        self.write_disk(first_key, art.as_deref());
        art.map(Bytes::from)
    }

    /// `Some(None)` is a remembered miss.
    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)))
    }

    /// An empty file records that there is no art. Failure to write is only a
    /// lost cache entry. Requests that miss the same cover together each write
    /// a temporary file of their own, so the one renamed into place last is
    /// whole rather than another's half-written file.
    fn write_disk(&self, key: &str, art: Option<&[u8]>) {
        static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
        let _ = std::fs::create_dir_all(&self.dir);
        let n = WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        let tmp = self
            .dir
            .join(format!("{key}.{}-{n}.tmp", std::process::id()));
        if std::fs::write(&tmp, art.unwrap_or_default()).is_ok()
            && std::fs::rename(&tmp, self.dir.join(key)).is_err()
        {
            let _ = std::fs::remove_file(&tmp);
        }
    }
}

/// Where a cover is read from.
enum Source {
    /// An image file beside the track.
    Image(PathBuf),
    /// The art embedded in the track itself.
    Embedded(PathBuf),
}

impl Source {
    fn path(&self) -> &Path {
        match self {
            Self::Image(p) | Self::Embedded(p) => p,
        }
    }
}

/// The source's path, size and mtime, and the size asked for: a re-tagged or
/// replaced file is a different key, and stale entries are simply never read.
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")
}

/// Fit within `size` pixels on a side and encode as JPEG. A JPEG already
/// within bounds is passed through untouched.
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 writers_racing_on_one_cover_leave_it_whole() {
        let dir = tempfile::tempdir().unwrap();
        let covers = Covers::new(dir.path().to_path_buf());
        let arts: Vec<Vec<u8>> = (0..8u8).map(|i| vec![i; 256 * 1024]).collect();
        for _ in 0..20 {
            std::thread::scope(|s| {
                for art in &arts {
                    s.spawn(|| covers.write_disk("k-400.jpg", Some(art)));
                }
            });
            let got = std::fs::read(dir.path().join("k-400.jpg")).unwrap();
            assert!(arts.contains(&got), "a torn write of {} bytes", got.len());
        }
        let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
        assert_eq!(left.len(), 1, "no temporary files left behind");
    }

    #[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"
        );
    }
}