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#[serde(rename_all = "lowercase")]
20pub enum SourceMode {
21 Mirror,
23 Copy,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub enum ArtifactKind {
40 CoverJpg,
42 CoverWebp,
48 DetailsTxt,
50 LyricsTxt,
52 Lrc,
54 VideoMp4,
57 FolderJpg,
59 FolderWebp,
61 FolderMp4,
65 Playlist,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
71#[serde(rename_all = "lowercase")]
72pub enum AudioFormat {
73 Mp3,
74 #[default]
75 Flac,
76 Wav,
77 Alac,
78}
79
80impl AudioFormat {
81 pub fn ext(self) -> &'static str {
85 match self {
86 Self::Mp3 => "mp3",
87 Self::Flac => "flac",
88 Self::Wav => "wav",
89 Self::Alac => "m4a",
90 }
91 }
92
93 pub fn embeds_animated_cover(self) -> bool {
98 !matches!(self, Self::Alac)
99 }
100}
101
102impl FromStr for AudioFormat {
103 type Err = Error;
104
105 fn from_str(s: &str) -> Result<Self> {
106 match s.to_ascii_lowercase().as_str() {
107 "mp3" => Ok(Self::Mp3),
108 "flac" => Ok(Self::Flac),
109 "wav" => Ok(Self::Wav),
110 "alac" => Ok(Self::Alac),
111 other => Err(Error::Config(format!("unknown format '{other}'"))),
112 }
113 }
114}
115
116impl fmt::Display for AudioFormat {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self {
119 Self::Mp3 => f.write_str("mp3"),
120 Self::Flac => f.write_str("flac"),
121 Self::Wav => f.write_str("wav"),
122 Self::Alac => f.write_str("alac"),
123 }
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
135#[serde(rename_all = "lowercase")]
136pub enum StemFormat {
137 #[default]
139 Wav,
140 Mp3,
142}
143
144impl StemFormat {
145 pub fn ext(self) -> &'static str {
147 match self {
148 Self::Wav => "wav",
149 Self::Mp3 => "mp3",
150 }
151 }
152}
153
154impl FromStr for StemFormat {
155 type Err = Error;
156
157 fn from_str(s: &str) -> Result<Self> {
158 match s.to_ascii_lowercase().as_str() {
159 "wav" => Ok(Self::Wav),
160 "mp3" => Ok(Self::Mp3),
161 "flac" => Err(Error::Config(
162 "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
163 )),
164 other => Err(Error::Config(format!("unknown stem format '{other}'"))),
165 }
166 }
167}
168
169impl fmt::Display for StemFormat {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 f.write_str(self.ext())
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
177#[serde(rename_all = "lowercase")]
178pub enum VideoCoverRetention {
179 #[default]
180 Neither,
181 Webp,
182 Mp4,
183 Both,
184}
185
186impl VideoCoverRetention {
187 pub fn keeps_webp(self) -> bool {
188 matches!(self, Self::Webp | Self::Both)
189 }
190
191 pub fn keeps_mp4(self) -> bool {
192 matches!(self, Self::Mp4 | Self::Both)
193 }
194}
195
196impl FromStr for VideoCoverRetention {
197 type Err = Error;
198
199 fn from_str(s: &str) -> Result<Self> {
200 match s.to_ascii_lowercase().as_str() {
201 "neither" => Ok(Self::Neither),
202 "webp" => Ok(Self::Webp),
203 "mp4" => Ok(Self::Mp4),
204 "both" => Ok(Self::Both),
205 other => Err(Error::Config(format!(
206 "unknown video_cover_retention '{other}'"
207 ))),
208 }
209 }
210}
211
212impl fmt::Display for VideoCoverRetention {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 match self {
215 Self::Neither => f.write_str("neither"),
216 Self::Webp => f.write_str("webp"),
217 Self::Mp4 => f.write_str("mp4"),
218 Self::Both => f.write_str("both"),
219 }
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub struct WebpEncodeSettings {
235 pub quality: u8,
238 pub max_fps: u32,
240 pub max_width: Option<u32>,
244 pub lossless: bool,
248 pub compression_level: u8,
251}
252
253impl Default for WebpEncodeSettings {
254 fn default() -> Self {
255 Self {
256 quality: 90,
257 max_fps: 24,
258 max_width: Some(640),
259 lossless: false,
260 compression_level: 4,
261 }
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn audio_format_parses_case_insensitively() {
271 assert_eq!("FLAC".parse::<AudioFormat>().unwrap(), AudioFormat::Flac);
272 assert_eq!("Mp3".parse::<AudioFormat>().unwrap(), AudioFormat::Mp3);
273 assert_eq!("wav".parse::<AudioFormat>().unwrap(), AudioFormat::Wav);
274 assert_eq!("alac".parse::<AudioFormat>().unwrap(), AudioFormat::Alac);
275 }
276
277 #[test]
278 fn audio_format_rejects_unknown_without_panicking() {
279 assert!(matches!(
280 "ogg".parse::<AudioFormat>().unwrap_err(),
281 Error::Config(_)
282 ));
283 }
284
285 #[test]
286 fn audio_format_default_is_flac() {
287 assert_eq!(AudioFormat::default(), AudioFormat::Flac);
288 }
289
290 #[test]
291 fn audio_format_ext_differs_from_display_for_alac() {
292 assert_eq!(AudioFormat::Alac.ext(), "m4a");
293 assert_eq!(AudioFormat::Alac.to_string(), "alac");
294 }
295
296 #[test]
297 fn audio_format_display_round_trips_through_from_str() {
298 for f in [
299 AudioFormat::Mp3,
300 AudioFormat::Flac,
301 AudioFormat::Wav,
302 AudioFormat::Alac,
303 ] {
304 assert_eq!(f.to_string().parse::<AudioFormat>().unwrap(), f);
305 }
306 }
307
308 #[test]
309 fn audio_format_embeds_animated_cover_except_alac() {
310 assert!(AudioFormat::Flac.embeds_animated_cover());
311 assert!(AudioFormat::Mp3.embeds_animated_cover());
312 assert!(AudioFormat::Wav.embeds_animated_cover());
313 assert!(!AudioFormat::Alac.embeds_animated_cover());
314 }
315
316 #[test]
317 fn stem_format_parses_wav_and_mp3() {
318 assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
319 assert_eq!("mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
320 }
321
322 #[test]
323 fn stem_format_rejects_flac_with_guidance() {
324 match "flac".parse::<StemFormat>().unwrap_err() {
325 Error::Config(msg) => assert!(msg.contains("FLAC")),
326 other => panic!("expected Config error, got {other:?}"),
327 }
328 }
329
330 #[test]
331 fn stem_format_rejects_unknown_without_panicking() {
332 assert!(matches!(
333 "ogg".parse::<StemFormat>().unwrap_err(),
334 Error::Config(_)
335 ));
336 }
337
338 #[test]
339 fn stem_format_default_is_wav_and_display_matches_ext() {
340 assert_eq!(StemFormat::default(), StemFormat::Wav);
341 assert_eq!(StemFormat::Mp3.to_string(), StemFormat::Mp3.ext());
342 }
343
344 #[test]
345 fn video_cover_retention_parses_all_variants() {
346 assert_eq!(
347 "neither".parse::<VideoCoverRetention>().unwrap(),
348 VideoCoverRetention::Neither
349 );
350 assert_eq!(
351 "WEBP".parse::<VideoCoverRetention>().unwrap(),
352 VideoCoverRetention::Webp
353 );
354 assert_eq!(
355 "mp4".parse::<VideoCoverRetention>().unwrap(),
356 VideoCoverRetention::Mp4
357 );
358 assert_eq!(
359 "both".parse::<VideoCoverRetention>().unwrap(),
360 VideoCoverRetention::Both
361 );
362 }
363
364 #[test]
365 fn video_cover_retention_rejects_unknown_without_panicking() {
366 assert!(matches!(
367 "all".parse::<VideoCoverRetention>().unwrap_err(),
368 Error::Config(_)
369 ));
370 }
371
372 #[test]
373 fn video_cover_retention_keeps_matrix() {
374 assert!(!VideoCoverRetention::Neither.keeps_webp());
375 assert!(!VideoCoverRetention::Neither.keeps_mp4());
376 assert!(VideoCoverRetention::Webp.keeps_webp());
377 assert!(!VideoCoverRetention::Webp.keeps_mp4());
378 assert!(!VideoCoverRetention::Mp4.keeps_webp());
379 assert!(VideoCoverRetention::Mp4.keeps_mp4());
380 assert!(VideoCoverRetention::Both.keeps_webp());
381 assert!(VideoCoverRetention::Both.keeps_mp4());
382 }
383
384 #[test]
385 fn video_cover_retention_default_is_neither() {
386 assert_eq!(VideoCoverRetention::default(), VideoCoverRetention::Neither);
387 }
388
389 #[test]
390 fn webp_defaults_fit_the_flac_picture_ceiling() {
391 let d = WebpEncodeSettings::default();
392 assert_eq!(d.quality, 90);
393 assert_eq!(d.max_fps, 24);
394 assert_eq!(d.max_width, Some(640));
395 assert!(!d.lossless);
396 assert_eq!(d.compression_level, 4);
397 }
398}