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
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[serde(rename_all = "lowercase")]
96pub enum AudioFormat {
97 Mp3,
98 #[default]
99 Flac,
100 Wav,
101 Alac,
102}
103
104impl AudioFormat {
105 pub fn ext(self) -> &'static str {
109 match self {
110 Self::Mp3 => "mp3",
111 Self::Flac => "flac",
112 Self::Wav => "wav",
113 Self::Alac => "m4a",
114 }
115 }
116
117 pub fn embeds_animated_cover(self) -> bool {
122 !matches!(self, Self::Alac)
123 }
124}
125
126impl FromStr for AudioFormat {
127 type Err = Error;
128
129 fn from_str(s: &str) -> Result<Self> {
133 match s {
134 "mp3" => Ok(Self::Mp3),
135 "flac" => Ok(Self::Flac),
136 "wav" => Ok(Self::Wav),
137 "alac" => Ok(Self::Alac),
138 other => Err(Error::Config(format!("unknown format '{other}'"))),
139 }
140 }
141}
142
143impl fmt::Display for AudioFormat {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 match self {
146 Self::Mp3 => f.write_str("mp3"),
147 Self::Flac => f.write_str("flac"),
148 Self::Wav => f.write_str("wav"),
149 Self::Alac => f.write_str("alac"),
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163#[serde(rename_all = "lowercase")]
164pub enum StemFormat {
165 #[default]
167 Wav,
168 Mp3,
170}
171
172impl StemFormat {
173 pub fn ext(self) -> &'static str {
175 match self {
176 Self::Wav => "wav",
177 Self::Mp3 => "mp3",
178 }
179 }
180}
181
182impl FromStr for StemFormat {
183 type Err = Error;
184
185 fn from_str(s: &str) -> Result<Self> {
188 match s {
189 "wav" => Ok(Self::Wav),
190 "mp3" => Ok(Self::Mp3),
191 "flac" => Err(Error::Config(
192 "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
193 )),
194 other => Err(Error::Config(format!("unknown stem format '{other}'"))),
195 }
196 }
197}
198
199impl fmt::Display for StemFormat {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 f.write_str(self.ext())
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208#[serde(rename_all = "lowercase")]
209pub enum VideoCoverRetention {
210 #[default]
211 Neither,
212 Webp,
213 Mp4,
214 Both,
215}
216
217impl VideoCoverRetention {
218 pub fn keeps_webp(self) -> bool {
219 matches!(self, Self::Webp | Self::Both)
220 }
221
222 pub fn keeps_mp4(self) -> bool {
223 matches!(self, Self::Mp4 | Self::Both)
224 }
225}
226
227impl FromStr for VideoCoverRetention {
228 type Err = Error;
229
230 fn from_str(s: &str) -> Result<Self> {
233 match s {
234 "neither" => Ok(Self::Neither),
235 "webp" => Ok(Self::Webp),
236 "mp4" => Ok(Self::Mp4),
237 "both" => Ok(Self::Both),
238 other => Err(Error::Config(format!(
239 "unknown video_cover_retention '{other}'"
240 ))),
241 }
242 }
243}
244
245impl fmt::Display for VideoCoverRetention {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 match self {
248 Self::Neither => f.write_str("neither"),
249 Self::Webp => f.write_str("webp"),
250 Self::Mp4 => f.write_str("mp4"),
251 Self::Both => f.write_str("both"),
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub struct WebpEncodeSettings {
268 pub quality: u8,
271 pub max_fps: u32,
273 pub max_width: Option<u32>,
277 pub lossless: bool,
281 pub compression_level: u8,
284}
285
286impl Default for WebpEncodeSettings {
287 fn default() -> Self {
288 Self {
289 quality: 90,
290 max_fps: 24,
291 max_width: Some(640),
292 lossless: false,
293 compression_level: 4,
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn audio_format_parses_lowercase_only() {
304 assert_eq!("flac".parse::<AudioFormat>().unwrap(), AudioFormat::Flac);
305 assert_eq!("mp3".parse::<AudioFormat>().unwrap(), AudioFormat::Mp3);
306 assert_eq!("wav".parse::<AudioFormat>().unwrap(), AudioFormat::Wav);
307 assert_eq!("alac".parse::<AudioFormat>().unwrap(), AudioFormat::Alac);
308 assert!("FLAC".parse::<AudioFormat>().is_err());
310 assert!("Mp3".parse::<AudioFormat>().is_err());
311 }
312
313 #[test]
314 fn audio_format_rejects_unknown_without_panicking() {
315 assert!(matches!(
316 "ogg".parse::<AudioFormat>().unwrap_err(),
317 Error::Config(_)
318 ));
319 }
320
321 #[test]
322 fn audio_format_default_is_flac() {
323 assert_eq!(AudioFormat::default(), AudioFormat::Flac);
324 }
325
326 #[test]
327 fn sidecar_suffix_maps_each_per_clip_kind() {
328 assert_eq!(ArtifactKind::CoverJpg.sidecar_suffix(), Some(".jpg"));
329 assert_eq!(ArtifactKind::CoverWebp.sidecar_suffix(), Some(".webp"));
330 assert_eq!(
331 ArtifactKind::DetailsTxt.sidecar_suffix(),
332 Some(".details.txt")
333 );
334 assert_eq!(
335 ArtifactKind::LyricsTxt.sidecar_suffix(),
336 Some(".lyrics.txt")
337 );
338 assert_eq!(ArtifactKind::Lrc.sidecar_suffix(), Some(".lrc"));
339 assert_eq!(ArtifactKind::VideoMp4.sidecar_suffix(), Some(".mp4"));
340 }
341
342 #[test]
343 fn sidecar_suffix_is_none_for_album_and_library_kinds() {
344 assert_eq!(ArtifactKind::FolderJpg.sidecar_suffix(), None);
345 assert_eq!(ArtifactKind::FolderWebp.sidecar_suffix(), None);
346 assert_eq!(ArtifactKind::FolderMp4.sidecar_suffix(), None);
347 assert_eq!(ArtifactKind::Playlist.sidecar_suffix(), None);
348 }
349
350 #[test]
351 fn audio_format_ext_differs_from_display_for_alac() {
352 assert_eq!(AudioFormat::Alac.ext(), "m4a");
353 assert_eq!(AudioFormat::Alac.to_string(), "alac");
354 }
355
356 #[test]
357 fn audio_format_display_round_trips_through_from_str() {
358 for f in [
359 AudioFormat::Mp3,
360 AudioFormat::Flac,
361 AudioFormat::Wav,
362 AudioFormat::Alac,
363 ] {
364 assert_eq!(f.to_string().parse::<AudioFormat>().unwrap(), f);
365 }
366 }
367
368 #[test]
369 fn audio_format_embeds_animated_cover_except_alac() {
370 assert!(AudioFormat::Flac.embeds_animated_cover());
371 assert!(AudioFormat::Mp3.embeds_animated_cover());
372 assert!(AudioFormat::Wav.embeds_animated_cover());
373 assert!(!AudioFormat::Alac.embeds_animated_cover());
374 }
375
376 #[test]
377 fn stem_format_parses_wav_and_mp3() {
378 assert_eq!("wav".parse::<StemFormat>().unwrap(), StemFormat::Wav);
379 assert_eq!("mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
380 assert!("WAV".parse::<StemFormat>().is_err());
382 }
383
384 #[test]
385 fn stem_format_rejects_flac_with_guidance() {
386 match "flac".parse::<StemFormat>().unwrap_err() {
387 Error::Config(msg) => assert!(msg.contains("FLAC")),
388 other => panic!("expected Config error, got {other:?}"),
389 }
390 }
391
392 #[test]
393 fn stem_format_rejects_unknown_without_panicking() {
394 assert!(matches!(
395 "ogg".parse::<StemFormat>().unwrap_err(),
396 Error::Config(_)
397 ));
398 }
399
400 #[test]
401 fn stem_format_default_is_wav_and_display_matches_ext() {
402 assert_eq!(StemFormat::default(), StemFormat::Wav);
403 assert_eq!(StemFormat::Mp3.to_string(), StemFormat::Mp3.ext());
404 }
405
406 #[test]
407 fn video_cover_retention_parses_all_variants() {
408 assert_eq!(
409 "neither".parse::<VideoCoverRetention>().unwrap(),
410 VideoCoverRetention::Neither
411 );
412 assert_eq!(
413 "webp".parse::<VideoCoverRetention>().unwrap(),
414 VideoCoverRetention::Webp
415 );
416 assert_eq!(
417 "mp4".parse::<VideoCoverRetention>().unwrap(),
418 VideoCoverRetention::Mp4
419 );
420 assert_eq!(
421 "both".parse::<VideoCoverRetention>().unwrap(),
422 VideoCoverRetention::Both
423 );
424 assert!("WEBP".parse::<VideoCoverRetention>().is_err());
426 }
427
428 #[test]
429 fn video_cover_retention_rejects_unknown_without_panicking() {
430 assert!(matches!(
431 "all".parse::<VideoCoverRetention>().unwrap_err(),
432 Error::Config(_)
433 ));
434 }
435
436 #[test]
437 fn video_cover_retention_keeps_matrix() {
438 assert!(!VideoCoverRetention::Neither.keeps_webp());
439 assert!(!VideoCoverRetention::Neither.keeps_mp4());
440 assert!(VideoCoverRetention::Webp.keeps_webp());
441 assert!(!VideoCoverRetention::Webp.keeps_mp4());
442 assert!(!VideoCoverRetention::Mp4.keeps_webp());
443 assert!(VideoCoverRetention::Mp4.keeps_mp4());
444 assert!(VideoCoverRetention::Both.keeps_webp());
445 assert!(VideoCoverRetention::Both.keeps_mp4());
446 }
447
448 #[test]
449 fn video_cover_retention_default_is_neither() {
450 assert_eq!(VideoCoverRetention::default(), VideoCoverRetention::Neither);
451 }
452
453 #[test]
454 fn webp_defaults_fit_the_flac_picture_ceiling() {
455 let d = WebpEncodeSettings::default();
456 assert_eq!(d.quality, 90);
457 assert_eq!(d.max_fps, 24);
458 assert_eq!(d.max_width, Some(640));
459 assert!(!d.lossless);
460 assert_eq!(d.compression_level, 4);
461 }
462}