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