selene-core 0.2.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::{
    base_dirs,
    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()
    }
}

/// 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()
}

/// 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 get_or_cache_cover_art(source: &CoverArt, size: usize) -> Result<PathBuf, FfmpegError> {
    let cover_dir = base_dirs().cache_dir().join("covers");
    fs::create_dir_all(&cover_dir)?;

    let scale = format!("scale={size}:{size}:flags=lanczos");

    let output_file = cover_dir.join(format!("{id}_{size}_{size}.webp", id = source.hash()));

    // ffmpeg -v error -i <INPUT> -map 0:v:0 -frames:v 1 -filter:v scale=<SIZE>:<SIZE>:flags=lanczos -c:v libwebp -qscale:v 80 -compression_level 6 -y cover.webp
    let mut command = Command::new("ffmpeg");
    command.args(["-v", "error", "-i"]);
    command.arg(source.source());
    command.args(["-map", "0:v:0", "-frames:v", "1", "-filter-v"]);
    command.arg(scale);
    command.args([
        "-c:v",
        "libwebp",
        "-qscale:v",
        "80",
        "-compression_level",
        "6",
        "-y",
    ]);
    command.arg(output_file.as_path());

    let output = command.output()?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr);
        return Err(FfmpegError::FfmpegWrapperError(err.into()));
    }

    Ok(output_file)
}

/// 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()))
}