selene-core 0.3.0

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

use blake3::{Hash, hash};
use serde::{Deserialize, Serialize};

use crate::{
    cache_dir,
    errors::FfmpegError,
    ffmpeg::{command_mutators::FfmpegPresets, ffmpeg, output_ffmpeg},
};

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

impl CoverArt {
    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, FfmpegError> {
        let path = path.as_ref();
        Ok(Self {
            hash: hash_video_stream(path)?,
            path: path.to_path_buf(),
        })
    }

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

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

    /// Extracts the image from the file and caches it with the given identifier
    ///
    /// The 'Identifier' for track cover art should ALWAYS be the hash of the image
    /// Art is cached in the `CACHE_DIR` with the name `{hash}_{x_width}_{y_width}.webp`
    /// The extracted file is LOSSY, not LOSSLESS
    pub fn cache_file(&self, y: usize, x: usize) -> Result<PathBuf, FfmpegError> {
        let cover_dir = cache_dir().join("covers");

        let cache_file = cover_dir.join(format!("{id}_{x}_{y}.webp", id = self.hash()));

        if cache_file.exists() {
            return Ok(cache_file);
        }

        // ffmpeg -v error -i <INPUT> -map 0:v:0 -frames:v 1 -filter:v scale=<X>:<Y>:flags=lanczos -c:v libwebp -qscale:v 80 -compression_level 6 -y cover.webp
        let mut command = ffmpeg();

        command.input_file(self.source());
        command.args(["-map", "0:v:0", "-frames:v", "1", "-filter:v"]);
        command.arg(format!("scale={x}:{y}:flags=lanczos"));
        command.args([
            "-c:v",
            "libwebp",
            "-qscale:v",
            "80",
            "-compression_level",
            "6",
            "-y",
        ]);
        command.arg(&cache_file);

        fs::create_dir_all(&cover_dir)?;
        output_ffmpeg(command)?;

        Ok(cache_file)
    }
}

/// Returns true if the file has a video stream
pub fn has_video_stream(path: impl AsRef<Path>) -> bool {
    let path = path.as_ref();

    let output = Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-select_streams",
            "v",
            "-show_entries",
            "stream=index",
            "-of",
            "csv=p=0",
            path.to_str().unwrap(),
        ])
        .output();

    let Ok(output) = output else { return false };

    !output.stdout.is_empty()
}

/// Hash the first frame of the video stream
///
/// This is done to make caching more efficient, if two songs share the same cover art, only one instance is stored
pub fn hash_video_stream(path: impl AsRef<Path>) -> Result<Hash, FfmpegError> {
    let path = path.as_ref();

    // ffmpeg -i <file> -map 0:v:0 -c copy -f rawvideo -
    let mut command = ffmpeg();
    command.input_file(path);
    command.args(["-map", "0:v:0", "-c", "copy", "-f", "rawvideo", "-"]);

    let result = output_ffmpeg(command)?;

    Ok(hash(result.as_bytes()))
}