selene-core 0.8.0

selene-core is the backend for Selene, a local-first music player
Documentation
use std::{
    fs::{self},
    io::{self, BufWriter},
    path::{Path, PathBuf},
};

use blake3::Hash;
use image::{DynamicImage, ImageError, imageops::FilterType};
use lofty::{
    error::LoftyError,
    picture::{Picture, PictureType},
};
use lunar_lib::trace;
use serde::{Deserialize, Serialize};

use crate::{cache_dir, utils::hash_file};

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
pub struct ImageArt {
    hash: Hash,
    source: PathBuf,
}

pub trait CacheableArt {
    fn cache_file(&self, x: u32, y: u32) -> io::Result<PathBuf>;

    fn cache_image(&self, x: u32, y: u32) -> io::Result<(DynamicImage, PathBuf)>;

    fn hash(&self) -> Hash;
}

impl ImageArt {
    pub(crate) fn from_raw(source: PathBuf, hash: Hash) -> Self {
        Self { hash, source }
    }

    pub fn from_file(source: impl Into<PathBuf>) -> io::Result<Self> {
        let source = source.into();
        let hash = hash_file(&source)?;
        Ok(Self { hash, source })
    }

    #[must_use]
    pub fn hash(&self) -> Hash {
        self.hash
    }

    #[must_use]
    pub fn source(&self) -> &Path {
        &self.source
    }

    pub fn to_picture(&self) -> Result<Picture, LoftyError> {
        let image = image::open(&self.source).expect("Image art should be pre-validated");
        let mut reader = io::Cursor::new(image.into_bytes());
        let mut picture = Picture::from_reader(&mut reader)?;
        picture.set_pic_type(PictureType::CoverFront);
        Ok(picture)
    }
}

impl CacheableArt for ImageArt {
    fn cache_file(&self, x: u32, y: u32) -> io::Result<PathBuf> {
        let cover_dir = cache_dir().join("covers");
        let cache_file = cover_dir.join(format!("{id}_{x}_{x}.jpeg", id = self.hash()));
        if cache_file.exists() {
            Ok(cache_file)
        } else {
            self.cache_image(x, y).map(|(_, p)| p)
        }
    }

    fn cache_image(&self, x: u32, y: u32) -> io::Result<(DynamicImage, PathBuf)> {
        let cover_dir = cache_dir().join("covers");
        let cache_file = cover_dir.join(format!("{id}_{x}_{x}.jpeg", id = self.hash()));
        if cache_file.exists() {
            let image = image::open(&cache_file).expect("Cache art was modified or corrupted");
            return Ok((image, cache_file));
        }

        let image = image::open(&self.source)
            .expect("Image art should be pre-validated")
            .resize_to_fill(x, y, FilterType::Lanczos3);

        cache_image(&image, &cache_file)?;

        Ok((image, cache_file))
    }

    fn hash(&self) -> Hash {
        self.hash
    }
}

pub fn hash_image(source: impl AsRef<Path>) -> Result<Hash, ImageError> {
    let image = image::open(source)?;
    Ok(blake3::hash(image.as_bytes()))
}

pub fn cache_image(image: &DynamicImage, path: impl AsRef<Path>) -> io::Result<()> {
    let path = path.as_ref();

    trace!("Starting image encoding for {}", path.display());
    fs::create_dir_all(path.parent().unwrap())?;
    let writer = BufWriter::new(
        fs::OpenOptions::new()
            .write(true)
            .truncate(true)
            .create(true)
            .open(path)?,
    );

    let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(writer, 80);
    encoder.encode_image(image).map_err(io::Error::other)?;
    trace!("Finished image encoding for {}", path.display());

    Ok(())
}