use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum SourceMode {
Mirror,
Copy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ArtifactKind {
CoverJpg,
CoverWebp,
DetailsTxt,
LyricsTxt,
Lrc,
VideoMp4,
FolderJpg,
FolderWebp,
FolderMp4,
Playlist,
}
impl ArtifactKind {
pub(crate) fn sidecar_suffix(self) -> Option<&'static str> {
Some(match self {
Self::CoverJpg => ".jpg",
Self::CoverWebp => ".webp",
Self::DetailsTxt => ".details.txt",
Self::LyricsTxt => ".lyrics.txt",
Self::Lrc => ".lrc",
Self::VideoMp4 => ".mp4",
Self::FolderJpg | Self::FolderWebp | Self::FolderMp4 | Self::Playlist => {
return None;
}
})
}
pub(crate) fn is_per_clip(self) -> bool {
self.sidecar_suffix().is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum AudioFormat {
Mp3,
#[default]
Flac,
Wav,
Alac,
}
impl AudioFormat {
pub fn ext(self) -> &'static str {
match self {
Self::Mp3 => "mp3",
Self::Flac => "flac",
Self::Wav => "wav",
Self::Alac => "m4a",
}
}
pub fn embeds_animated_cover(self) -> bool {
!matches!(self, Self::Alac)
}
}
impl FromStr for AudioFormat {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"mp3" => Ok(Self::Mp3),
"flac" => Ok(Self::Flac),
"wav" => Ok(Self::Wav),
"alac" => Ok(Self::Alac),
other => Err(Error::Config(format!("unknown format '{other}'"))),
}
}
}
impl fmt::Display for AudioFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Mp3 => f.write_str("mp3"),
Self::Flac => f.write_str("flac"),
Self::Wav => f.write_str("wav"),
Self::Alac => f.write_str("alac"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum StemFormat {
#[default]
Wav,
Mp3,
}
impl StemFormat {
pub fn ext(self) -> &'static str {
match self {
Self::Wav => "wav",
Self::Mp3 => "mp3",
}
}
}
impl FromStr for StemFormat {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"wav" => Ok(Self::Wav),
"mp3" => Ok(Self::Mp3),
"flac" => Err(Error::Config(
"stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
)),
other => Err(Error::Config(format!("unknown stem format '{other}'"))),
}
}
}
impl fmt::Display for StemFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.ext())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum VideoCoverRetention {
#[default]
Neither,
Webp,
Mp4,
Both,
}
impl VideoCoverRetention {
pub fn keeps_webp(self) -> bool {
matches!(self, Self::Webp | Self::Both)
}
pub fn keeps_mp4(self) -> bool {
matches!(self, Self::Mp4 | Self::Both)
}
}
impl FromStr for VideoCoverRetention {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"neither" => Ok(Self::Neither),
"webp" => Ok(Self::Webp),
"mp4" => Ok(Self::Mp4),
"both" => Ok(Self::Both),
other => Err(Error::Config(format!(
"unknown video_cover_retention '{other}'"
))),
}
}
}
impl fmt::Display for VideoCoverRetention {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Neither => f.write_str("neither"),
Self::Webp => f.write_str("webp"),
Self::Mp4 => f.write_str("mp4"),
Self::Both => f.write_str("both"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WebpEncodeSettings {
pub quality: u8,
pub max_fps: u32,
pub max_width: Option<u32>,
pub lossless: bool,
pub compression_level: u8,
}
impl Default for WebpEncodeSettings {
fn default() -> Self {
Self {
quality: 90,
max_fps: 24,
max_width: Some(640),
lossless: false,
compression_level: 4,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_format_parses_lowercase_only() {
assert_eq!("flac".parse::<AudioFormat>().unwrap(), AudioFormat::Flac);
assert_eq!("mp3".parse::<AudioFormat>().unwrap(), AudioFormat::Mp3);
assert_eq!("wav".parse::<AudioFormat>().unwrap(), AudioFormat::Wav);
assert_eq!("alac".parse::<AudioFormat>().unwrap(), AudioFormat::Alac);
assert!("FLAC".parse::<AudioFormat>().is_err());
assert!("Mp3".parse::<AudioFormat>().is_err());
}
#[test]
fn audio_format_rejects_unknown_without_panicking() {
assert!(matches!(
"ogg".parse::<AudioFormat>().unwrap_err(),
Error::Config(_)
));
}
#[test]
fn audio_format_default_is_flac() {
assert_eq!(AudioFormat::default(), AudioFormat::Flac);
}
#[test]
fn sidecar_suffix_maps_each_per_clip_kind() {
assert_eq!(ArtifactKind::CoverJpg.sidecar_suffix(), Some(".jpg"));
assert_eq!(ArtifactKind::CoverWebp.sidecar_suffix(), Some(".webp"));
assert_eq!(
ArtifactKind::DetailsTxt.sidecar_suffix(),
Some(".details.txt")
);
assert_eq!(
ArtifactKind::LyricsTxt.sidecar_suffix(),
Some(".lyrics.txt")
);
assert_eq!(ArtifactKind::Lrc.sidecar_suffix(), Some(".lrc"));
assert_eq!(ArtifactKind::VideoMp4.sidecar_suffix(), Some(".mp4"));
}
#[test]
fn sidecar_suffix_is_none_for_album_and_library_kinds() {
assert_eq!(ArtifactKind::FolderJpg.sidecar_suffix(), None);
assert_eq!(ArtifactKind::FolderWebp.sidecar_suffix(), None);
assert_eq!(ArtifactKind::FolderMp4.sidecar_suffix(), None);
assert_eq!(ArtifactKind::Playlist.sidecar_suffix(), None);
}
#[test]
fn is_per_clip_matches_sidecar_suffix_set() {
for kind in [
ArtifactKind::CoverJpg,
ArtifactKind::CoverWebp,
ArtifactKind::DetailsTxt,
ArtifactKind::LyricsTxt,
ArtifactKind::Lrc,
ArtifactKind::VideoMp4,
] {
assert!(kind.is_per_clip(), "{kind:?} is a per-clip sidecar");
assert_eq!(kind.is_per_clip(), kind.sidecar_suffix().is_some());
}
for kind in [
ArtifactKind::FolderJpg,
ArtifactKind::FolderWebp,
ArtifactKind::FolderMp4,
ArtifactKind::Playlist,
] {
assert!(!kind.is_per_clip(), "{kind:?} is album/library-scoped");
assert_eq!(kind.is_per_clip(), kind.sidecar_suffix().is_some());
}
}
#[test]
fn audio_format_ext_differs_from_display_for_alac() {
assert_eq!(AudioFormat::Alac.ext(), "m4a");
assert_eq!(AudioFormat::Alac.to_string(), "alac");
}
#[test]
fn audio_format_display_round_trips_through_from_str() {
for f in [
AudioFormat::Mp3,
AudioFormat::Flac,
AudioFormat::Wav,
AudioFormat::Alac,
] {
assert_eq!(f.to_string().parse::<AudioFormat>().unwrap(), f);
}
}
#[test]
fn audio_format_embeds_animated_cover_except_alac() {
assert!(AudioFormat::Flac.embeds_animated_cover());
assert!(AudioFormat::Mp3.embeds_animated_cover());
assert!(AudioFormat::Wav.embeds_animated_cover());
assert!(!AudioFormat::Alac.embeds_animated_cover());
}
#[test]
fn stem_format_parses_wav_and_mp3() {
assert_eq!("wav".parse::<StemFormat>().unwrap(), StemFormat::Wav);
assert_eq!("mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
assert!("WAV".parse::<StemFormat>().is_err());
}
#[test]
fn stem_format_rejects_flac_with_guidance() {
match "flac".parse::<StemFormat>().unwrap_err() {
Error::Config(msg) => assert!(msg.contains("FLAC")),
other => panic!("expected Config error, got {other:?}"),
}
}
#[test]
fn stem_format_rejects_unknown_without_panicking() {
assert!(matches!(
"ogg".parse::<StemFormat>().unwrap_err(),
Error::Config(_)
));
}
#[test]
fn stem_format_default_is_wav_and_display_matches_ext() {
assert_eq!(StemFormat::default(), StemFormat::Wav);
assert_eq!(StemFormat::Mp3.to_string(), StemFormat::Mp3.ext());
}
#[test]
fn video_cover_retention_parses_all_variants() {
assert_eq!(
"neither".parse::<VideoCoverRetention>().unwrap(),
VideoCoverRetention::Neither
);
assert_eq!(
"webp".parse::<VideoCoverRetention>().unwrap(),
VideoCoverRetention::Webp
);
assert_eq!(
"mp4".parse::<VideoCoverRetention>().unwrap(),
VideoCoverRetention::Mp4
);
assert_eq!(
"both".parse::<VideoCoverRetention>().unwrap(),
VideoCoverRetention::Both
);
assert!("WEBP".parse::<VideoCoverRetention>().is_err());
}
#[test]
fn video_cover_retention_rejects_unknown_without_panicking() {
assert!(matches!(
"all".parse::<VideoCoverRetention>().unwrap_err(),
Error::Config(_)
));
}
#[test]
fn video_cover_retention_keeps_matrix() {
assert!(!VideoCoverRetention::Neither.keeps_webp());
assert!(!VideoCoverRetention::Neither.keeps_mp4());
assert!(VideoCoverRetention::Webp.keeps_webp());
assert!(!VideoCoverRetention::Webp.keeps_mp4());
assert!(!VideoCoverRetention::Mp4.keeps_webp());
assert!(VideoCoverRetention::Mp4.keeps_mp4());
assert!(VideoCoverRetention::Both.keeps_webp());
assert!(VideoCoverRetention::Both.keeps_mp4());
}
#[test]
fn video_cover_retention_default_is_neither() {
assert_eq!(VideoCoverRetention::default(), VideoCoverRetention::Neither);
}
#[test]
fn webp_defaults_fit_the_flac_picture_ceiling() {
let d = WebpEncodeSettings::default();
assert_eq!(d.quality, 90);
assert_eq!(d.max_fps, 24);
assert_eq!(d.max_width, Some(640));
assert!(!d.lossless);
assert_eq!(d.compression_level, 4);
}
}