selene-core 0.3.0

selene-core is the backend for Selene, a local-first music player
Documentation
use std::{collections::HashMap, ffi::OsStr, path::Path, process::Command, str::FromStr};

use crate::{
    codec::Codec,
    errors::{ExportError, FfmpegError},
    ffmpeg::command_mutators::FfmpegPresets,
    library::{
        metadata::{self, RawMetadata},
        track::Track,
    },
};
use serde::{Deserialize, Serialize};

pub mod command_mutators;
pub mod loudnorm;

// =============================================
//
// This module is a mess, It would be great if anyone
// could help organize it
//
// This module exists as a helper function for ffmpeg/ffprobe wrappers
// Nothing outside of it should be allowed to call their own
// ffmpeg/ffprobe commands, they must use wrappers from this module
//
// This makes code outside of this module much easier to manage
// aswell as much smaller
//
// =============================================

pub fn ffmpeg() -> Command {
    let mut command = Command::new("ffmpeg");
    command.args(["-hide_banner", "-loglevel", "error"]);
    command
}

pub fn ffprobe(log_level: impl AsRef<str>) -> Command {
    let mut command = Command::new("ffprobe");
    command.args(["-hide_banner", "-loglevel", log_level.as_ref()]);
    command
}

pub fn output_ffmpeg(mut command: Command) -> Result<String, FfmpegError> {
    if command.get_program() != OsStr::new("ffmpeg") {
        return Err(FfmpegError::FfmpegWrapperError(
            "Attempted to access non-ffmpeg command: Expected ffmpeg wrapper".to_owned(),
        ));
    }

    let output = command.output()?;

    if output.status.success() {
        let ok = String::from_utf8_lossy(&output.stdout).to_string();
        Ok(ok)
    } else {
        let err = String::from_utf8_lossy(&output.stderr).to_string();
        Err(FfmpegError::FfmpegWrapperError(err))
    }
}

pub fn output_ffmpeg_err(mut command: Command) -> Result<String, FfmpegError> {
    if command.get_program() != OsStr::new("ffmpeg") {
        return Err(FfmpegError::FfmpegWrapperError(
            "Attempted to access non-ffmpeg command: Expected ffmpeg wrapper".to_owned(),
        ));
    }

    let output = command.output()?;

    if output.status.success() {
        let ok = String::from_utf8_lossy(&output.stderr).to_string();
        Ok(ok)
    } else {
        let err = String::from_utf8_lossy(&output.stderr).to_string();
        Err(FfmpegError::FfmpegWrapperError(err))
    }
}

pub fn output_ffprobe(mut command: Command) -> Result<String, FfmpegError> {
    if command.get_program() != OsStr::new("ffprobe") {
        return Err(FfmpegError::FfprobeWrapperError(
            "Attempted to access non-ffprobe command: Expected ffprobe wrapper".to_owned(),
        ));
    }

    let output = command.output()?;

    if output.status.success() {
        let ok = String::from_utf8_lossy(&output.stdout).to_string();
        Ok(ok)
    } else {
        let err = String::from_utf8_lossy(&output.stderr).to_string();
        Err(FfmpegError::FfprobeWrapperError(err))
    }
}

pub fn ffprobe_media_container_json(path: impl AsRef<Path>) -> Result<String, FfmpegError> {
    let path = path.as_ref();

    let mut command = ffprobe("error");
    command.args([
        "-select_streams", "a:0",
        "-show_entries", "stream=codec_name,sample_rate,channels,channel_layout,duration,bits_per_raw_sample:format=duration,bit_rate,format_name",
        "-of", "json"
    ]);
    command.arg(path);

    output_ffprobe(command)
}

#[derive(Debug, PartialEq, PartialOrd, Clone, Serialize, Deserialize, Copy)]
pub struct Stream {
    pub(crate) codec: Codec,
    pub(crate) sample_rate: usize,
    pub(crate) channels: isize,
    pub(crate) channel_layout: Option<ChannelLayout>,
    pub(crate) duration: f32,
}
impl Stream {
    pub fn sample_rate(&self) -> usize {
        self.sample_rate
    }

    pub fn duration(&self) -> f32 {
        self.duration
    }
}

#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ChannelLayout {
    Mono,
    Stereo,
    TwoOne,
    ThreeZero,
    ThreeZeroBack,
    FourZero,
    Quad,
    QuadSide,
    ThreeOne,
    FiveZero,
    FiveZeroSide,
    FourOne,
    FiveOne,
    FiveOneSide,
    SixZero,
    SixZeroFront,
    ThreeOneTwo,
    Hexagonal,
    SixOne,
    SixOneFront,
    SevenZero,
    SevenZeroFront,
    SevenOne,
    SevenOneWide,
    SevenOneWideSide,
    FiveOneTwo,
    Octagonal,
    Cube,
    FiveOneFour,
    SevenOneTwo,
    SevenOneFour,
    SevenTwoThree,
    NineOneFour,
    NineOneSix,
    Hexadecagonal,
    Binaural,
    Downmix,
    TwentytwoTwo,
}

impl FromStr for ChannelLayout {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "mono" => Ok(ChannelLayout::Mono),
            "stereo" => Ok(ChannelLayout::Stereo),
            "2.1" => Ok(ChannelLayout::TwoOne),
            "3.0" => Ok(ChannelLayout::ThreeZero),
            "3.0(back)" => Ok(ChannelLayout::ThreeZeroBack),
            "4.0" => Ok(ChannelLayout::FourZero),
            "quad" => Ok(ChannelLayout::Quad),
            "quad(side)" => Ok(ChannelLayout::QuadSide),
            "3.1" => Ok(ChannelLayout::ThreeOne),
            "5.0" => Ok(ChannelLayout::FiveZero),
            "5.0(side)" => Ok(ChannelLayout::FiveZeroSide),
            "4.1" => Ok(ChannelLayout::FourOne),
            "5.1" => Ok(ChannelLayout::FiveOne),
            "5.1(side)" => Ok(ChannelLayout::FiveOneSide),
            "6.0" => Ok(ChannelLayout::SixZero),
            "6.0(front)" => Ok(ChannelLayout::SixZeroFront),
            "3.1.2" => Ok(ChannelLayout::ThreeOneTwo),
            "hexagonal" => Ok(ChannelLayout::Hexagonal),
            "6.1" => Ok(ChannelLayout::SixOne),
            "6.1(front)" => Ok(ChannelLayout::SixOneFront),
            "7.0" => Ok(ChannelLayout::SevenZero),
            "7.0(front)" => Ok(ChannelLayout::SevenZeroFront),
            "7.1" => Ok(ChannelLayout::SevenOne),
            "7.1(wide)" => Ok(ChannelLayout::SevenOneWide),
            "7.1(wide-side)" => Ok(ChannelLayout::SevenOneWideSide),
            "5.1.2" => Ok(ChannelLayout::FiveOneTwo),
            "octagonal" => Ok(ChannelLayout::Octagonal),
            "cube" => Ok(ChannelLayout::Cube),
            "5.1.4" => Ok(ChannelLayout::FiveOneFour),
            "7.1.2" => Ok(ChannelLayout::SevenOneTwo),
            "7.1.4" => Ok(ChannelLayout::SevenOneFour),
            "7.2.3" => Ok(ChannelLayout::SevenTwoThree),
            "9.1.4" => Ok(ChannelLayout::NineOneFour),
            "9.1.6" => Ok(ChannelLayout::NineOneSix),
            "hexadecagonal" => Ok(ChannelLayout::Hexadecagonal),
            "binaural" => Ok(ChannelLayout::Binaural),
            "downmix" => Ok(ChannelLayout::Downmix),
            "22.2" => Ok(ChannelLayout::TwentytwoTwo),
            _ => Err(format!("Unknown channel layout: {s}")),
        }
    }
}

pub fn ffprobe_format_tags(path: impl AsRef<Path>) -> Result<RawMetadata, FfmpegError> {
    #[derive(Deserialize)]
    struct Root {
        #[serde(default)]
        format: Tags,
    }

    #[derive(Deserialize, Default)]
    struct Tags {
        #[serde(default)]
        tags: HashMap<String, String>,
    }

    let mut command = ffprobe("error");

    // ffprobe -v error -show_entries format_tags -print_format json
    command.args(["-show_entries", "format_tags", "-print_format", "json"]);
    command.arg(path.as_ref());

    let json = output_ffprobe(command)?;

    let root: Root = serde_json::from_str(&json)?;
    let tags = root
        .format
        .tags
        .into_iter()
        .map(|(key, value)| (metadata::canonicalize_metadata_key(&key), value))
        .collect();

    Ok(tags)
}

pub fn export_set_metadata(track: &Track, export_to: impl AsRef<Path>) -> Result<(), ExportError> {
    let export_to = export_to.as_ref();

    let Some(lib_container) = track.lib_container() else {
        return Err(FfmpegError::Other(
            "Input track does not have a library file to export from".to_owned(),
        )
        .into());
    };

    let mut command = ffmpeg();
    command.input_file(lib_container.path());
    command.copy_all();
    command.drop_metadata();

    let metadata = track.metadata_key_values()?;
    command.add_metadata_group(metadata.iter());

    command.output_file(export_to);

    output_ffmpeg(command)?;

    Ok(())
}