1use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Error, Result};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(rename_all = "lowercase")]
21pub enum SourceMode {
22 Mirror,
24 Copy,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub enum ArtifactKind {
41 CoverJpg,
43 CoverWebp,
49 DetailsTxt,
51 LyricsTxt,
53 Lrc,
55 VideoMp4,
58 FolderJpg,
60 FolderWebp,
62 FolderMp4,
66 Playlist,
68}
69
70impl ArtifactKind {
71 pub(crate) fn sidecar_suffix(self) -> Option<&'static str> {
78 Some(match self {
79 Self::CoverJpg => ".jpg",
80 Self::CoverWebp => ".webp",
81 Self::DetailsTxt => ".details.txt",
82 Self::LyricsTxt => ".lyrics.txt",
83 Self::Lrc => ".lrc",
84 Self::VideoMp4 => ".mp4",
85 Self::FolderJpg | Self::FolderWebp | Self::FolderMp4 | Self::Playlist => {
86 return None;
87 }
88 })
89 }
90
91 pub(crate) fn is_per_clip(self) -> bool {
100 self.sidecar_suffix().is_some()
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107#[serde(rename_all = "lowercase")]
108pub enum AudioFormat {
109 Mp3,
110 #[default]
111 Flac,
112 Wav,
113 Alac,
114}
115
116impl AudioFormat {
117 pub fn ext(self) -> &'static str {
121 match self {
122 Self::Mp3 => "mp3",
123 Self::Flac => "flac",
124 Self::Wav => "wav",
125 Self::Alac => "m4a",
126 }
127 }
128
129 pub fn embeds_animated_cover(self) -> bool {
134 !matches!(self, Self::Alac)
135 }
136}
137
138impl FromStr for AudioFormat {
139 type Err = Error;
140
141 fn from_str(s: &str) -> Result<Self> {
145 match s {
146 "mp3" => Ok(Self::Mp3),
147 "flac" => Ok(Self::Flac),
148 "wav" => Ok(Self::Wav),
149 "alac" => Ok(Self::Alac),
150 other => Err(Error::Config(format!("unknown format '{other}'"))),
151 }
152 }
153}
154
155impl fmt::Display for AudioFormat {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 match self {
158 Self::Mp3 => f.write_str("mp3"),
159 Self::Flac => f.write_str("flac"),
160 Self::Wav => f.write_str("wav"),
161 Self::Alac => f.write_str("alac"),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175#[serde(rename_all = "lowercase")]
176pub enum StemFormat {
177 #[default]
179 Wav,
180 Mp3,
182}
183
184impl StemFormat {
185 pub fn ext(self) -> &'static str {
187 match self {
188 Self::Wav => "wav",
189 Self::Mp3 => "mp3",
190 }
191 }
192}
193
194impl FromStr for StemFormat {
195 type Err = Error;
196
197 fn from_str(s: &str) -> Result<Self> {
200 match s {
201 "wav" => Ok(Self::Wav),
202 "mp3" => Ok(Self::Mp3),
203 "flac" => Err(Error::Config(
204 "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
205 )),
206 other => Err(Error::Config(format!("unknown stem format '{other}'"))),
207 }
208 }
209}
210
211impl fmt::Display for StemFormat {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 f.write_str(self.ext())
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
220#[serde(rename_all = "lowercase")]
221pub enum VideoCoverRetention {
222 #[default]
223 Neither,
224 Webp,
225 Mp4,
226 Both,
227}
228
229impl VideoCoverRetention {
230 pub fn keeps_webp(self) -> bool {
231 matches!(self, Self::Webp | Self::Both)
232 }
233
234 pub fn keeps_mp4(self) -> bool {
235 matches!(self, Self::Mp4 | Self::Both)
236 }
237}
238
239impl FromStr for VideoCoverRetention {
240 type Err = Error;
241
242 fn from_str(s: &str) -> Result<Self> {
245 match s {
246 "neither" => Ok(Self::Neither),
247 "webp" => Ok(Self::Webp),
248 "mp4" => Ok(Self::Mp4),
249 "both" => Ok(Self::Both),
250 other => Err(Error::Config(format!(
251 "unknown video_cover_retention '{other}'"
252 ))),
253 }
254 }
255}
256
257impl fmt::Display for VideoCoverRetention {
258 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259 match self {
260 Self::Neither => f.write_str("neither"),
261 Self::Webp => f.write_str("webp"),
262 Self::Mp4 => f.write_str("mp4"),
263 Self::Both => f.write_str("both"),
264 }
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub struct WebpEncodeSettings {
280 pub quality: u8,
283 pub max_fps: u32,
285 pub max_width: Option<u32>,
289 pub lossless: bool,
293 pub compression_level: u8,
296}
297
298impl Default for WebpEncodeSettings {
299 fn default() -> Self {
300 Self {
301 quality: 90,
302 max_fps: 24,
303 max_width: Some(640),
304 lossless: false,
305 compression_level: 4,
306 }
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn audio_format_parses_lowercase_only() {
316 assert_eq!("flac".parse::<AudioFormat>().unwrap(), AudioFormat::Flac);
317 assert_eq!("mp3".parse::<AudioFormat>().unwrap(), AudioFormat::Mp3);
318 assert_eq!("wav".parse::<AudioFormat>().unwrap(), AudioFormat::Wav);
319 assert_eq!("alac".parse::<AudioFormat>().unwrap(), AudioFormat::Alac);
320 assert!("FLAC".parse::<AudioFormat>().is_err());
322 assert!("Mp3".parse::<AudioFormat>().is_err());
323 }
324
325 #[test]
326 fn audio_format_rejects_unknown_without_panicking() {
327 assert!(matches!(
328 "ogg".parse::<AudioFormat>().unwrap_err(),
329 Error::Config(_)
330 ));
331 }
332
333 #[test]
334 fn audio_format_default_is_flac() {
335 assert_eq!(AudioFormat::default(), AudioFormat::Flac);
336 }
337
338 #[test]
339 fn sidecar_suffix_maps_each_per_clip_kind() {
340 assert_eq!(ArtifactKind::CoverJpg.sidecar_suffix(), Some(".jpg"));
341 assert_eq!(ArtifactKind::CoverWebp.sidecar_suffix(), Some(".webp"));
342 assert_eq!(
343 ArtifactKind::DetailsTxt.sidecar_suffix(),
344 Some(".details.txt")
345 );
346 assert_eq!(
347 ArtifactKind::LyricsTxt.sidecar_suffix(),
348 Some(".lyrics.txt")
349 );
350 assert_eq!(ArtifactKind::Lrc.sidecar_suffix(), Some(".lrc"));
351 assert_eq!(ArtifactKind::VideoMp4.sidecar_suffix(), Some(".mp4"));
352 }
353
354 #[test]
355 fn sidecar_suffix_is_none_for_album_and_library_kinds() {
356 assert_eq!(ArtifactKind::FolderJpg.sidecar_suffix(), None);
357 assert_eq!(ArtifactKind::FolderWebp.sidecar_suffix(), None);
358 assert_eq!(ArtifactKind::FolderMp4.sidecar_suffix(), None);
359 assert_eq!(ArtifactKind::Playlist.sidecar_suffix(), None);
360 }
361
362 #[test]
363 fn is_per_clip_matches_sidecar_suffix_set() {
364 for kind in [
365 ArtifactKind::CoverJpg,
366 ArtifactKind::CoverWebp,
367 ArtifactKind::DetailsTxt,
368 ArtifactKind::LyricsTxt,
369 ArtifactKind::Lrc,
370 ArtifactKind::VideoMp4,
371 ] {
372 assert!(kind.is_per_clip(), "{kind:?} is a per-clip sidecar");
373 assert_eq!(kind.is_per_clip(), kind.sidecar_suffix().is_some());
374 }
375 for kind in [
376 ArtifactKind::FolderJpg,
377 ArtifactKind::FolderWebp,
378 ArtifactKind::FolderMp4,
379 ArtifactKind::Playlist,
380 ] {
381 assert!(!kind.is_per_clip(), "{kind:?} is album/library-scoped");
382 assert_eq!(kind.is_per_clip(), kind.sidecar_suffix().is_some());
383 }
384 }
385
386 #[test]
387 fn audio_format_ext_differs_from_display_for_alac() {
388 assert_eq!(AudioFormat::Alac.ext(), "m4a");
389 assert_eq!(AudioFormat::Alac.to_string(), "alac");
390 }
391
392 #[test]
393 fn audio_format_display_round_trips_through_from_str() {
394 for f in [
395 AudioFormat::Mp3,
396 AudioFormat::Flac,
397 AudioFormat::Wav,
398 AudioFormat::Alac,
399 ] {
400 assert_eq!(f.to_string().parse::<AudioFormat>().unwrap(), f);
401 }
402 }
403
404 #[test]
405 fn audio_format_embeds_animated_cover_except_alac() {
406 assert!(AudioFormat::Flac.embeds_animated_cover());
407 assert!(AudioFormat::Mp3.embeds_animated_cover());
408 assert!(AudioFormat::Wav.embeds_animated_cover());
409 assert!(!AudioFormat::Alac.embeds_animated_cover());
410 }
411
412 #[test]
413 fn stem_format_parses_wav_and_mp3() {
414 assert_eq!("wav".parse::<StemFormat>().unwrap(), StemFormat::Wav);
415 assert_eq!("mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
416 assert!("WAV".parse::<StemFormat>().is_err());
418 }
419
420 #[test]
421 fn stem_format_rejects_flac_with_guidance() {
422 match "flac".parse::<StemFormat>().unwrap_err() {
423 Error::Config(msg) => assert!(msg.contains("FLAC")),
424 other => panic!("expected Config error, got {other:?}"),
425 }
426 }
427
428 #[test]
429 fn stem_format_rejects_unknown_without_panicking() {
430 assert!(matches!(
431 "ogg".parse::<StemFormat>().unwrap_err(),
432 Error::Config(_)
433 ));
434 }
435
436 #[test]
437 fn stem_format_default_is_wav_and_display_matches_ext() {
438 assert_eq!(StemFormat::default(), StemFormat::Wav);
439 assert_eq!(StemFormat::Mp3.to_string(), StemFormat::Mp3.ext());
440 }
441
442 #[test]
443 fn video_cover_retention_parses_all_variants() {
444 assert_eq!(
445 "neither".parse::<VideoCoverRetention>().unwrap(),
446 VideoCoverRetention::Neither
447 );
448 assert_eq!(
449 "webp".parse::<VideoCoverRetention>().unwrap(),
450 VideoCoverRetention::Webp
451 );
452 assert_eq!(
453 "mp4".parse::<VideoCoverRetention>().unwrap(),
454 VideoCoverRetention::Mp4
455 );
456 assert_eq!(
457 "both".parse::<VideoCoverRetention>().unwrap(),
458 VideoCoverRetention::Both
459 );
460 assert!("WEBP".parse::<VideoCoverRetention>().is_err());
462 }
463
464 #[test]
465 fn video_cover_retention_rejects_unknown_without_panicking() {
466 assert!(matches!(
467 "all".parse::<VideoCoverRetention>().unwrap_err(),
468 Error::Config(_)
469 ));
470 }
471
472 #[test]
473 fn video_cover_retention_keeps_matrix() {
474 assert!(!VideoCoverRetention::Neither.keeps_webp());
475 assert!(!VideoCoverRetention::Neither.keeps_mp4());
476 assert!(VideoCoverRetention::Webp.keeps_webp());
477 assert!(!VideoCoverRetention::Webp.keeps_mp4());
478 assert!(!VideoCoverRetention::Mp4.keeps_webp());
479 assert!(VideoCoverRetention::Mp4.keeps_mp4());
480 assert!(VideoCoverRetention::Both.keeps_webp());
481 assert!(VideoCoverRetention::Both.keeps_mp4());
482 }
483
484 #[test]
485 fn video_cover_retention_default_is_neither() {
486 assert_eq!(VideoCoverRetention::default(), VideoCoverRetention::Neither);
487 }
488
489 #[test]
490 fn webp_defaults_fit_the_flac_picture_ceiling() {
491 let d = WebpEncodeSettings::default();
492 assert_eq!(d.quality, 90);
493 assert_eq!(d.max_fps, 24);
494 assert_eq!(d.max_width, Some(640));
495 assert!(!d.lossless);
496 assert_eq!(d.compression_level, 4);
497 }
498}