Skip to main content

suno_core/
config.rs

1//! Configuration model and precedence resolution.
2//!
3//! Parses a TOML string and merges in environment variables and CLI flag
4//! overrides supplied by the caller. Performs no disk or environment IO.
5
6use std::collections::{BTreeMap, HashMap};
7use std::fmt;
8use std::path::Path;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::ffmpeg::WebpEncodeSettings;
15use crate::naming::CharacterSet;
16use crate::reconcile::SourceMode;
17
18/// Audio format for downloaded clips.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
20#[serde(rename_all = "lowercase")]
21pub enum AudioFormat {
22    Mp3,
23    #[default]
24    Flac,
25    Wav,
26    Alac,
27}
28
29impl AudioFormat {
30    /// The on-disk file extension for a clip in this format. Kept separate from
31    /// the [`Display`](fmt::Display) token so a codec's container extension need
32    /// not match its config name.
33    pub fn ext(self) -> &'static str {
34        match self {
35            Self::Mp3 => "mp3",
36            Self::Flac => "flac",
37            Self::Wav => "wav",
38            Self::Alac => "m4a",
39        }
40    }
41}
42
43impl FromStr for AudioFormat {
44    type Err = Error;
45
46    fn from_str(s: &str) -> Result<Self> {
47        match s.to_ascii_lowercase().as_str() {
48            "mp3" => Ok(Self::Mp3),
49            "flac" => Ok(Self::Flac),
50            "wav" => Ok(Self::Wav),
51            "alac" => Ok(Self::Alac),
52            other => Err(Error::Config(format!("unknown format '{other}'"))),
53        }
54    }
55}
56
57impl fmt::Display for AudioFormat {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::Mp3 => f.write_str("mp3"),
61            Self::Flac => f.write_str("flac"),
62            Self::Wav => f.write_str("wav"),
63            Self::Alac => f.write_str("alac"),
64        }
65    }
66}
67
68/// Container format for a downloaded stem.
69///
70/// Stems are stored RAW in their native container and are never transcoded, so
71/// unlike [`AudioFormat`] there is no lossless-from-lossy render: WAV comes
72/// straight from Suno's free `convert_wav` endpoint and MP3 straight from the
73/// public CDN. FLAC is deliberately unrepresentable — a stem is never
74/// re-encoded to FLAC, even when the song's own format is FLAC.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[serde(rename_all = "lowercase")]
77pub enum StemFormat {
78    /// Lossless WAV via the free `convert_wav` render, stored as delivered.
79    #[default]
80    Wav,
81    /// The public CDN MP3, stored as delivered.
82    Mp3,
83}
84
85impl StemFormat {
86    /// The file extension for a stem stored in this format.
87    pub fn ext(self) -> &'static str {
88        match self {
89            Self::Wav => "wav",
90            Self::Mp3 => "mp3",
91        }
92    }
93}
94
95impl FromStr for StemFormat {
96    type Err = Error;
97
98    fn from_str(s: &str) -> Result<Self> {
99        match s.to_ascii_lowercase().as_str() {
100            "wav" => Ok(Self::Wav),
101            "mp3" => Ok(Self::Mp3),
102            "flac" => Err(Error::Config(
103                "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
104            )),
105            other => Err(Error::Config(format!("unknown stem format '{other}'"))),
106        }
107    }
108}
109
110impl fmt::Display for StemFormat {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.ext())
113    }
114}
115
116/// Which video-cover artifacts to retain.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
118#[serde(rename_all = "lowercase")]
119pub enum VideoCoverRetention {
120    #[default]
121    Neither,
122    Webp,
123    Mp4,
124    Both,
125}
126
127impl VideoCoverRetention {
128    pub fn keeps_webp(self) -> bool {
129        matches!(self, Self::Webp | Self::Both)
130    }
131
132    pub fn keeps_mp4(self) -> bool {
133        matches!(self, Self::Mp4 | Self::Both)
134    }
135}
136
137impl FromStr for VideoCoverRetention {
138    type Err = Error;
139
140    fn from_str(s: &str) -> Result<Self> {
141        match s.to_ascii_lowercase().as_str() {
142            "neither" => Ok(Self::Neither),
143            "webp" => Ok(Self::Webp),
144            "mp4" => Ok(Self::Mp4),
145            "both" => Ok(Self::Both),
146            other => Err(Error::Config(format!(
147                "unknown video_cover_retention '{other}'"
148            ))),
149        }
150    }
151}
152
153impl fmt::Display for VideoCoverRetention {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            Self::Neither => f.write_str("neither"),
157            Self::Webp => f.write_str("webp"),
158            Self::Mp4 => f.write_str("mp4"),
159            Self::Both => f.write_str("both"),
160        }
161    }
162}
163
164/// Global default settings applied when no account or source override applies.
165#[derive(Debug, Clone, Default, Deserialize)]
166pub struct Defaults {
167    pub format: Option<AudioFormat>,
168    pub concurrency: Option<u32>,
169    pub retries: Option<u32>,
170    pub min_newest: Option<u32>,
171    pub token_command: Option<String>,
172    pub animated_covers: Option<bool>,
173    pub video_cover_retention: Option<VideoCoverRetention>,
174    pub animated_cover_quality: Option<u8>,
175    pub animated_cover_max_fps: Option<u32>,
176    pub animated_cover_max_width: Option<u32>,
177    pub animated_cover_compression_level: Option<u8>,
178    pub details_sidecar: Option<bool>,
179    pub lyrics_sidecar: Option<bool>,
180    pub lrc_sidecar: Option<bool>,
181    pub video_mp4: Option<bool>,
182    pub download_stems: Option<bool>,
183    pub stem_format: Option<StemFormat>,
184    pub naming_template: Option<String>,
185    pub character_set: Option<CharacterSet>,
186}
187
188/// Per-source overridable settings within an account.
189#[derive(Debug, Clone, Default, Deserialize)]
190pub struct SourceConfig {
191    pub format: Option<AudioFormat>,
192    pub concurrency: Option<u32>,
193    pub retries: Option<u32>,
194    pub min_newest: Option<u32>,
195    pub token_command: Option<String>,
196    pub animated_covers: Option<bool>,
197    pub video_cover_retention: Option<VideoCoverRetention>,
198    pub animated_cover_quality: Option<u8>,
199    pub animated_cover_max_fps: Option<u32>,
200    pub animated_cover_max_width: Option<u32>,
201    pub animated_cover_compression_level: Option<u8>,
202    pub details_sidecar: Option<bool>,
203    pub lyrics_sidecar: Option<bool>,
204    pub lrc_sidecar: Option<bool>,
205    pub video_mp4: Option<bool>,
206    pub download_stems: Option<bool>,
207    pub stem_format: Option<StemFormat>,
208    pub naming_template: Option<String>,
209    pub character_set: Option<CharacterSet>,
210}
211
212/// Configuration for a single named account.
213#[derive(Debug, Clone, Default, Deserialize)]
214pub struct AccountConfig {
215    pub token: Option<String>,
216    pub token_command: Option<String>,
217    pub root: Option<String>,
218    /// Optional Suno user id to assert this account authenticates as, refusing
219    /// to run on a mismatch (a belt-and-braces check alongside the on-disk
220    /// owner pin in the lineage store).
221    pub account_id: Option<String>,
222    pub format: Option<AudioFormat>,
223    pub concurrency: Option<u32>,
224    pub retries: Option<u32>,
225    pub min_newest: Option<u32>,
226    pub animated_covers: Option<bool>,
227    pub video_cover_retention: Option<VideoCoverRetention>,
228    pub animated_cover_quality: Option<u8>,
229    pub animated_cover_max_fps: Option<u32>,
230    pub animated_cover_max_width: Option<u32>,
231    pub animated_cover_compression_level: Option<u8>,
232    pub details_sidecar: Option<bool>,
233    pub lyrics_sidecar: Option<bool>,
234    pub lrc_sidecar: Option<bool>,
235    pub video_mp4: Option<bool>,
236    pub download_stems: Option<bool>,
237    pub stem_format: Option<StemFormat>,
238    pub naming_template: Option<String>,
239    pub character_set: Option<CharacterSet>,
240    #[serde(default)]
241    pub sources: HashMap<String, SourceConfig>,
242    /// Per-area mode selection (`sync` vs `copy`) for this account's library,
243    /// liked feed, and playlists. Absent means the classic single-verb run.
244    pub areas: Option<AreasConfig>,
245    /// Manual album-name overrides, keyed by the album's stable lineage root id
246    /// (`<root_id> = "Preferred Name"`). Album identity is the lineage root, so
247    /// the override is account-wide (like lineage), never per-source: the
248    /// derived title is unstable and is exactly what this replaces. An empty or
249    /// whitespace-only value is ignored, so a stray key cannot blank an album.
250    #[serde(default)]
251    pub albums: HashMap<String, String>,
252}
253
254/// How a single area treats deletion, including the library-only `off` value.
255///
256/// `off` is expressible only for the library area: it deliberately arms deletion
257/// of library-exclusive files by suppressing the implicit copy-protector, so a
258/// typo can never silently disarm that safety. `copy` and `mirror` map straight
259/// onto the matching [`SourceMode`].
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum AreaMode {
262    /// Suppress the implicit library copy-protector (arm library deletions).
263    Off,
264    /// Treat the area with the given [`SourceMode`].
265    Mode(SourceMode),
266}
267
268impl<'de> Deserialize<'de> for AreaMode {
269    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
270    where
271        D: serde::Deserializer<'de>,
272    {
273        let raw = String::deserialize(deserializer)?;
274        match raw.as_str() {
275            "off" => Ok(AreaMode::Off),
276            "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
277            "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
278            other => Err(serde::de::Error::custom(format!(
279                "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
280            ))),
281        }
282    }
283}
284
285/// Per-area mode selection for an account.
286///
287/// `library` accepts `off`/`copy`/`mirror`; `liked` and `playlists` accept
288/// `copy`/`mirror`; `playlist` overrides individual playlists by canonical Suno
289/// id. `deny_unknown_fields` turns a mistyped key (e.g. `libary`) into a parse
290/// error rather than a silent no-op. The `playlist` map cannot carry
291/// `deny_unknown_fields` (its keys are dynamic playlist ids), but every value is
292/// a closed [`SourceMode`], so a bad mode string still errors at parse time.
293#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
294#[serde(deny_unknown_fields)]
295pub struct AreasConfig {
296    pub library: Option<AreaMode>,
297    pub liked: Option<SourceMode>,
298    pub playlists: Option<SourceMode>,
299    #[serde(default)]
300    pub playlist: HashMap<String, SourceMode>,
301}
302
303/// Top-level configuration parsed from a TOML file.
304#[derive(Debug, Clone, Default, Deserialize)]
305pub struct Config {
306    #[serde(default)]
307    pub defaults: Defaults,
308    #[serde(default)]
309    pub accounts: HashMap<String, AccountConfig>,
310}
311
312impl Config {
313    /// Parse `toml_str` and validate the result.
314    ///
315    /// Validation rejects any pair of accounts whose root directories nest
316    /// inside one another. Duplicate account labels are rejected by the TOML
317    /// parser itself.
318    pub fn from_toml(toml_str: &str) -> Result<Self> {
319        let config: Self = toml::from_str(toml_str).map_err(|e| {
320            // Strip source-context lines (those containing " | ") to prevent
321            // token values from being echoed in error messages.
322            let raw = e.to_string();
323            let msg = raw
324                .lines()
325                .filter(|l| !l.contains(" | "))
326                .collect::<Vec<_>>()
327                .join("\n")
328                .trim()
329                .to_owned();
330            Error::Config(if msg.is_empty() {
331                "parse error".into()
332            } else {
333                msg
334            })
335        })?;
336        config.validate()?;
337        Ok(config)
338    }
339
340    fn validate(&self) -> Result<()> {
341        let roots: Vec<(&str, &str)> = self
342            .accounts
343            .iter()
344            .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
345            .collect();
346
347        for (i, (label_a, root_a)) in roots.iter().enumerate() {
348            for (label_b, root_b) in roots.iter().skip(i + 1) {
349                let a = Path::new(root_a);
350                let b = Path::new(root_b);
351                if a.starts_with(b) || b.starts_with(a) {
352                    return Err(Error::Config(format!(
353                        "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
354                    )));
355                }
356            }
357        }
358
359        let mut prefix_seen: HashMap<String, &str> = HashMap::new();
360        for label in self.accounts.keys() {
361            let prefix = label_to_env(label);
362            if let Some(other) = prefix_seen.get(&prefix) {
363                return Err(Error::Config(format!(
364                    "accounts '{label}' and '{other}' share env prefix '{prefix}'"
365                )));
366            }
367            prefix_seen.insert(prefix, label.as_str());
368        }
369
370        Ok(())
371    }
372
373    /// Compute effective settings for `account`, optionally scoped to `source`.
374    ///
375    /// The caller supplies the full environment map and any CLI flag overrides.
376    /// Precedence per field: flag > per-account env > global env > per-source
377    /// file > per-account file > global file defaults > compiled default.
378    pub fn resolve(
379        &self,
380        account: &str,
381        source: Option<&str>,
382        env: &HashMap<String, String>,
383        flags: &FlagOverrides,
384    ) -> Result<EffectiveSettings> {
385        let acc = self
386            .accounts
387            .get(account)
388            .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
389
390        let src = source.and_then(|s| acc.sources.get(s));
391        let label_env = label_to_env(account);
392
393        // Look up per-account env first, falling back to global.
394        let env_val = |suffix: &str| -> Option<&str> {
395            env.get(&format!("SUNO_{label_env}_{suffix}"))
396                .or_else(|| env.get(&format!("SUNO_{suffix}")))
397                .map(String::as_str)
398        };
399
400        let format_from_env = env_val("FORMAT")
401            .map(str::parse::<AudioFormat>)
402            .transpose()?;
403
404        let format = flags
405            .format
406            .or(format_from_env)
407            .or_else(|| src.and_then(|s| s.format))
408            .or(acc.format)
409            .or(self.defaults.format)
410            .unwrap_or(AudioFormat::Flac);
411
412        let concurrency = resolve_parsed(
413            flags.concurrency,
414            env_val("CONCURRENCY"),
415            src.and_then(|s| s.concurrency),
416            acc.concurrency,
417            self.defaults.concurrency,
418            4,
419            "CONCURRENCY",
420        )?;
421
422        let retries = resolve_parsed(
423            flags.retries,
424            env_val("RETRIES"),
425            src.and_then(|s| s.retries),
426            acc.retries,
427            self.defaults.retries,
428            3,
429            "RETRIES",
430        )?;
431
432        let min_newest = resolve_parsed(
433            flags.min_newest,
434            env_val("MIN_NEWEST"),
435            src.and_then(|s| s.min_newest),
436            acc.min_newest,
437            self.defaults.min_newest,
438            1,
439            "MIN_NEWEST",
440        )?;
441
442        let animated_covers = resolve_parsed(
443            flags.animated_covers,
444            env_val("ANIMATED_COVERS"),
445            src.and_then(|s| s.animated_covers),
446            acc.animated_covers,
447            self.defaults.animated_covers,
448            false,
449            "ANIMATED_COVERS",
450        )?;
451
452        let details_sidecar = resolve_parsed(
453            flags.details_sidecar,
454            env_val("DETAILS_SIDECAR"),
455            src.and_then(|s| s.details_sidecar),
456            acc.details_sidecar,
457            self.defaults.details_sidecar,
458            false,
459            "DETAILS_SIDECAR",
460        )?;
461
462        let lyrics_sidecar = resolve_parsed(
463            flags.lyrics_sidecar,
464            env_val("LYRICS_SIDECAR"),
465            src.and_then(|s| s.lyrics_sidecar),
466            acc.lyrics_sidecar,
467            self.defaults.lyrics_sidecar,
468            false,
469            "LYRICS_SIDECAR",
470        )?;
471
472        let lrc_sidecar = resolve_parsed(
473            flags.lrc_sidecar,
474            env_val("LRC_SIDECAR"),
475            src.and_then(|s| s.lrc_sidecar),
476            acc.lrc_sidecar,
477            self.defaults.lrc_sidecar,
478            false,
479            "LRC_SIDECAR",
480        )?;
481
482        let video_mp4 = resolve_parsed(
483            flags.video_mp4,
484            env_val("VIDEO_MP4"),
485            src.and_then(|s| s.video_mp4),
486            acc.video_mp4,
487            self.defaults.video_mp4,
488            false,
489            "VIDEO_MP4",
490        )?;
491
492        let download_stems = resolve_parsed(
493            flags.download_stems,
494            env_val("DOWNLOAD_STEMS"),
495            src.and_then(|s| s.download_stems),
496            acc.download_stems,
497            self.defaults.download_stems,
498            false,
499            "DOWNLOAD_STEMS",
500        )?;
501
502        let stem_format_from_env = env_val("STEM_FORMAT")
503            .map(str::parse::<StemFormat>)
504            .transpose()?;
505        let stem_format = flags
506            .stem_format
507            .or(stem_format_from_env)
508            .or_else(|| src.and_then(|s| s.stem_format))
509            .or(acc.stem_format)
510            .or(self.defaults.stem_format)
511            .unwrap_or_default();
512
513        let video_cover_retention = resolve_enum(
514            flags.video_cover_retention,
515            env_val("VIDEO_COVER_RETENTION"),
516            src.and_then(|s| s.video_cover_retention),
517            acc.video_cover_retention,
518            self.defaults.video_cover_retention,
519            None,
520            "VIDEO_COVER_RETENTION",
521        )?;
522        // `video_cover_retention`, when set, is the unified control for the
523        // album video-cover artifacts: `webp`/`both` keep the transcoded
524        // `cover.webp` (and the per-song `.webp`), `mp4`/`both` keep the raw
525        // `cover.mp4` (`video_cover_url` verbatim). The standalone music video
526        // (`video_url`) is a different asset and stays on its own `video_mp4`
527        // toggle, untouched here.
528        let (animated_covers, raw_animated_cover) = match video_cover_retention {
529            Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
530            None => (animated_covers, false),
531        };
532
533        let defaults_webp = WebpEncodeSettings::default();
534        let animated_cover_quality = resolve_u8_ranged(
535            flags.animated_cover_quality,
536            env_val("ANIMATED_COVER_QUALITY"),
537            src.and_then(|s| s.animated_cover_quality),
538            acc.animated_cover_quality,
539            self.defaults.animated_cover_quality,
540            defaults_webp.quality,
541            "ANIMATED_COVER_QUALITY",
542            0..=100,
543        )?;
544        let animated_cover_max_fps = resolve_parsed(
545            flags.animated_cover_max_fps,
546            env_val("ANIMATED_COVER_MAX_FPS"),
547            src.and_then(|s| s.animated_cover_max_fps),
548            acc.animated_cover_max_fps,
549            self.defaults.animated_cover_max_fps,
550            defaults_webp.max_fps,
551            "ANIMATED_COVER_MAX_FPS",
552        )?;
553        let animated_cover_max_width_from_env = env_val("ANIMATED_COVER_MAX_WIDTH")
554            .map(|s| {
555                s.parse().map_err(|_| {
556                    Error::Config(format!(
557                        "invalid ANIMATED_COVER_MAX_WIDTH: '{s}' (expected integer)"
558                    ))
559                })
560            })
561            .transpose()?;
562        let animated_cover_max_width = if let Some(v) = flags.animated_cover_max_width {
563            Some(v)
564        } else if let Some(v) = animated_cover_max_width_from_env {
565            Some(v)
566        } else {
567            src.and_then(|s| s.animated_cover_max_width)
568                .or(acc.animated_cover_max_width)
569                .or(self.defaults.animated_cover_max_width)
570                .or(defaults_webp.max_width)
571        };
572        let animated_cover_compression_level = resolve_u8_ranged(
573            flags.animated_cover_compression_level,
574            env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
575            src.and_then(|s| s.animated_cover_compression_level),
576            acc.animated_cover_compression_level,
577            self.defaults.animated_cover_compression_level,
578            defaults_webp.compression_level,
579            "ANIMATED_COVER_COMPRESSION_LEVEL",
580            0..=6,
581        )?;
582
583        let naming_template_from_env = env_val("NAMING_TEMPLATE").map(str::to_owned);
584        let naming_template = flags
585            .naming_template
586            .clone()
587            .or(naming_template_from_env)
588            .or_else(|| src.and_then(|s| s.naming_template.clone()))
589            .or_else(|| acc.naming_template.clone())
590            .or_else(|| self.defaults.naming_template.clone())
591            .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
592
593        let character_set_from_env = env_val("CHARACTER_SET")
594            .map(str::parse::<CharacterSet>)
595            .transpose()?;
596        let character_set = flags
597            .character_set
598            .or(character_set_from_env)
599            .or_else(|| src.and_then(|s| s.character_set))
600            .or(acc.character_set)
601            .or(self.defaults.character_set)
602            .unwrap_or(CharacterSet::Unicode);
603
604        let token = flags
605            .token
606            .clone()
607            .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
608            .or_else(|| env.get("SUNO_TOKEN").cloned());
609
610        let token_command = env
611            .get(&format!("SUNO_{label_env}_TOKEN_COMMAND"))
612            .cloned()
613            .or_else(|| env.get("SUNO_TOKEN_COMMAND").cloned())
614            .or_else(|| src.and_then(|s| s.token_command.clone()))
615            .or_else(|| acc.token_command.clone())
616            .or_else(|| self.defaults.token_command.clone());
617
618        Ok(EffectiveSettings {
619            token,
620            stored_token: acc.token.clone(),
621            token_command,
622            account_id: acc.account_id.clone(),
623            format,
624            concurrency,
625            retries,
626            min_newest,
627            animated_covers,
628            raw_animated_cover,
629            video_cover_retention: match (animated_covers, raw_animated_cover) {
630                (false, false) => VideoCoverRetention::Neither,
631                (true, false) => VideoCoverRetention::Webp,
632                (false, true) => VideoCoverRetention::Mp4,
633                (true, true) => VideoCoverRetention::Both,
634            },
635            animated_cover_webp: WebpEncodeSettings {
636                quality: animated_cover_quality,
637                max_fps: animated_cover_max_fps,
638                max_width: animated_cover_max_width,
639                lossless: defaults_webp.lossless,
640                compression_level: animated_cover_compression_level,
641            },
642            details_sidecar,
643            lyrics_sidecar,
644            lrc_sidecar,
645            video_mp4,
646            download_stems,
647            stem_format,
648            naming_template,
649            character_set,
650            areas: acc.areas.clone(),
651            album_overrides: acc
652                .albums
653                .iter()
654                .filter(|(_, name)| !name.trim().is_empty())
655                .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
656                .collect(),
657        })
658    }
659}
660
661fn resolve_parsed<T>(
662    flag: Option<T>,
663    env_str: Option<&str>,
664    src: Option<T>,
665    acc: Option<T>,
666    defaults: Option<T>,
667    compiled: T,
668    name: &str,
669) -> Result<T>
670where
671    T: FromStr + Copy,
672{
673    if let Some(v) = flag {
674        return Ok(v);
675    }
676    if let Some(s) = env_str {
677        return s
678            .parse()
679            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
680    }
681    Ok(src.or(acc).or(defaults).unwrap_or(compiled))
682}
683
684#[allow(clippy::too_many_arguments)]
685fn resolve_u8_ranged(
686    flag: Option<u8>,
687    env_str: Option<&str>,
688    src: Option<u8>,
689    acc: Option<u8>,
690    defaults: Option<u8>,
691    compiled: u8,
692    name: &str,
693    range: std::ops::RangeInclusive<u8>,
694) -> Result<u8> {
695    let value = if let Some(v) = flag {
696        v
697    } else if let Some(s) = env_str {
698        s.parse()
699            .map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
700    } else {
701        src.or(acc).or(defaults).unwrap_or(compiled)
702    };
703    if range.contains(&value) {
704        Ok(value)
705    } else {
706        Err(Error::Config(format!(
707            "invalid {name}: '{value}' (expected {}..={})",
708            range.start(),
709            range.end()
710        )))
711    }
712}
713
714fn resolve_enum<T>(
715    flag: Option<T>,
716    env_str: Option<&str>,
717    src: Option<T>,
718    acc: Option<T>,
719    defaults: Option<T>,
720    compiled: Option<T>,
721    name: &str,
722) -> Result<Option<T>>
723where
724    T: FromStr<Err = Error> + Copy,
725{
726    if let Some(v) = flag {
727        return Ok(Some(v));
728    }
729    if let Some(s) = env_str {
730        return s
731            .parse()
732            .map(Some)
733            .map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
734    }
735    Ok(src.or(acc).or(defaults).or(compiled))
736}
737
738/// Convert an account label to its environment variable prefix, mirroring the
739/// per-account keys the resolver reads: `my-lib` becomes `MY_LIB` for lookups
740/// like `SUNO_MY_LIB_TOKEN`.
741pub fn label_to_env(label: &str) -> String {
742    label.to_ascii_uppercase().replace('-', "_")
743}
744
745/// CLI flag overrides passed to [`Config::resolve`]. `None` means the flag
746/// was not provided.
747#[derive(Debug, Default)]
748pub struct FlagOverrides {
749    pub token: Option<String>,
750    pub format: Option<AudioFormat>,
751    pub concurrency: Option<u32>,
752    pub retries: Option<u32>,
753    pub min_newest: Option<u32>,
754    pub animated_covers: Option<bool>,
755    pub video_cover_retention: Option<VideoCoverRetention>,
756    pub animated_cover_quality: Option<u8>,
757    pub animated_cover_max_fps: Option<u32>,
758    pub animated_cover_max_width: Option<u32>,
759    pub animated_cover_compression_level: Option<u8>,
760    pub details_sidecar: Option<bool>,
761    pub lyrics_sidecar: Option<bool>,
762    pub lrc_sidecar: Option<bool>,
763    pub video_mp4: Option<bool>,
764    pub download_stems: Option<bool>,
765    pub stem_format: Option<StemFormat>,
766    pub naming_template: Option<String>,
767    pub character_set: Option<CharacterSet>,
768}
769
770/// Resolved effective settings for one account/source combination.
771#[derive(Debug, Clone, PartialEq)]
772pub struct EffectiveSettings {
773    /// A direct token from `--token` or `SUNO_*_TOKEN`.
774    pub token: Option<String>,
775    /// A stored token from `[accounts.<label>].token`.
776    pub stored_token: Option<String>,
777    /// A command to run for the token when no direct token was supplied.
778    pub token_command: Option<String>,
779    /// The optional configured account id assertion (see [`AccountConfig`]).
780    pub account_id: Option<String>,
781    pub format: AudioFormat,
782    pub concurrency: u32,
783    pub retries: u32,
784    pub min_newest: u32,
785    pub animated_covers: bool,
786    /// Keep the raw album `cover.mp4` (`video_cover_url` verbatim, no transcode).
787    /// Driven by [`VideoCoverRetention::keeps_mp4`]; independent of `video_mp4`.
788    pub raw_animated_cover: bool,
789    pub video_cover_retention: VideoCoverRetention,
790    pub animated_cover_webp: WebpEncodeSettings,
791    pub details_sidecar: bool,
792    pub lyrics_sidecar: bool,
793    pub lrc_sidecar: bool,
794    pub video_mp4: bool,
795    pub download_stems: bool,
796    pub stem_format: StemFormat,
797    pub naming_template: String,
798    pub character_set: CharacterSet,
799    /// The per-account `[areas]` selection table, if configured.
800    pub areas: Option<AreasConfig>,
801    /// Manual album-name overrides, keyed by lineage root id, resolved from the
802    /// account's `[accounts.<label>.albums]` table. Deterministically ordered
803    /// (a [`BTreeMap`]) and pre-trimmed of empty values by [`Config::resolve`].
804    pub album_overrides: BTreeMap<String, String>,
805}
806
807impl EffectiveSettings {
808    /// Returns `true` when these settings require ffmpeg to be on `PATH`.
809    ///
810    /// Lossless output (FLAC or ALAC) transcodes from the WAV render, and an
811    /// animated WebP cover transcodes MP4→WebP, so either needs ffmpeg. Keeping
812    /// the raw MP4 alongside the WebP (the `both` retention) still produces the
813    /// WebP, so `animated_covers` alone decides it; a raw-MP4-only run, or a
814    /// plain MP3/WAV run with no animated covers, needs no ffmpeg.
815    pub fn requires_ffmpeg(&self) -> bool {
816        matches!(self.format, AudioFormat::Flac | AudioFormat::Alac) || self.animated_covers
817    }
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    fn no_env() -> HashMap<String, String> {
825        HashMap::new()
826    }
827
828    fn no_flags() -> FlagOverrides {
829        FlagOverrides::default()
830    }
831
832    #[test]
833    fn parse_empty_toml() {
834        let cfg = Config::from_toml("").unwrap();
835        assert!(cfg.accounts.is_empty());
836    }
837
838    #[test]
839    fn parse_basic_account() {
840        let toml = r#"
841            [accounts.alice]
842            token = "tok"
843            root = "/music"
844        "#;
845        let cfg = Config::from_toml(toml).unwrap();
846        let acc = &cfg.accounts["alice"];
847        assert_eq!(acc.token.as_deref(), Some("tok"));
848        assert_eq!(acc.root.as_deref(), Some("/music"));
849    }
850
851    #[test]
852    fn account_id_parses_and_resolves() {
853        let toml = r#"
854            [accounts.alice]
855            token = "tok"
856            root = "/music"
857            account_id = "user_abc123"
858        "#;
859        let cfg = Config::from_toml(toml).unwrap();
860        assert_eq!(
861            cfg.accounts["alice"].account_id.as_deref(),
862            Some("user_abc123")
863        );
864        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
865        assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
866    }
867
868    #[test]
869    fn parse_defaults_section() {
870        let toml = r#"
871            [defaults]
872            format = "mp3"
873            concurrency = 8
874            retries = 5
875            min_newest = 2
876            animated_covers = true
877            video_cover_retention = "both"
878            animated_cover_quality = 85
879            animated_cover_max_fps = 18
880            animated_cover_max_width = 720
881            animated_cover_compression_level = 4
882        "#;
883        let cfg = Config::from_toml(toml).unwrap();
884        assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
885        assert_eq!(cfg.defaults.concurrency, Some(8));
886        assert_eq!(cfg.defaults.retries, Some(5));
887        assert_eq!(cfg.defaults.min_newest, Some(2));
888        assert_eq!(cfg.defaults.animated_covers, Some(true));
889        assert_eq!(
890            cfg.defaults.video_cover_retention,
891            Some(VideoCoverRetention::Both)
892        );
893        assert_eq!(cfg.defaults.animated_cover_quality, Some(85));
894        assert_eq!(cfg.defaults.animated_cover_max_fps, Some(18));
895        assert_eq!(cfg.defaults.animated_cover_max_width, Some(720));
896        assert_eq!(cfg.defaults.animated_cover_compression_level, Some(4));
897    }
898
899    #[test]
900    fn compiled_defaults_when_nothing_set() {
901        let toml = "[accounts.alice]\n";
902        let cfg = Config::from_toml(toml).unwrap();
903        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
904        assert_eq!(
905            eff,
906            EffectiveSettings {
907                token: None,
908                stored_token: None,
909                token_command: None,
910                account_id: None,
911                format: AudioFormat::Flac,
912                concurrency: 4,
913                retries: 3,
914                min_newest: 1,
915                animated_covers: false,
916                raw_animated_cover: false,
917                video_cover_retention: VideoCoverRetention::Neither,
918                animated_cover_webp: WebpEncodeSettings::default(),
919                details_sidecar: false,
920                lyrics_sidecar: false,
921                lrc_sidecar: false,
922                video_mp4: false,
923                download_stems: false,
924                stem_format: StemFormat::Wav,
925                naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
926                character_set: CharacterSet::Unicode,
927                areas: None,
928                album_overrides: BTreeMap::new(),
929            }
930        );
931    }
932
933    #[test]
934    fn file_defaults_override_compiled() {
935        let toml = r#"
936            [defaults]
937            format = "mp3"
938            concurrency = 8
939
940            [accounts.alice]
941        "#;
942        let cfg = Config::from_toml(toml).unwrap();
943        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
944        assert_eq!(eff.format, AudioFormat::Mp3);
945        assert_eq!(eff.concurrency, 8);
946        assert_eq!(eff.retries, 3); // compiled default
947    }
948
949    #[test]
950    fn account_settings_override_defaults() {
951        let toml = r#"
952            [defaults]
953            format = "mp3"
954
955            [accounts.alice]
956            format = "wav"
957        "#;
958        let cfg = Config::from_toml(toml).unwrap();
959        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
960        assert_eq!(eff.format, AudioFormat::Wav);
961    }
962
963    #[test]
964    fn per_source_overrides_account() {
965        let toml = r#"
966            [accounts.alice]
967            format = "flac"
968
969            [accounts.alice.sources.liked]
970            format = "mp3"
971        "#;
972        let cfg = Config::from_toml(toml).unwrap();
973        let eff = cfg
974            .resolve("alice", Some("liked"), &no_env(), &no_flags())
975            .unwrap();
976        assert_eq!(eff.format, AudioFormat::Mp3);
977    }
978
979    #[test]
980    fn unknown_source_falls_back_to_account() {
981        let toml = r#"
982            [accounts.alice]
983            format = "wav"
984        "#;
985        let cfg = Config::from_toml(toml).unwrap();
986        let eff = cfg
987            .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
988            .unwrap();
989        assert_eq!(eff.format, AudioFormat::Wav);
990    }
991
992    #[test]
993    fn global_env_overrides_file() {
994        let toml = r#"
995            [accounts.alice]
996            format = "flac"
997        "#;
998        let cfg = Config::from_toml(toml).unwrap();
999        let env: HashMap<String, String> =
1000            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1001        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1002        assert_eq!(eff.format, AudioFormat::Mp3);
1003    }
1004
1005    #[test]
1006    fn per_account_env_overrides_global_env() {
1007        let toml = "[accounts.alice]\n";
1008        let cfg = Config::from_toml(toml).unwrap();
1009        let env: HashMap<String, String> = [
1010            ("SUNO_FORMAT".into(), "mp3".into()),
1011            ("SUNO_ALICE_FORMAT".into(), "wav".into()),
1012        ]
1013        .into_iter()
1014        .collect();
1015        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1016        assert_eq!(eff.format, AudioFormat::Wav);
1017    }
1018
1019    #[test]
1020    fn per_account_env_label_uppersnakedcase() {
1021        let toml = "[accounts.my-lib]\n";
1022        let cfg = Config::from_toml(toml).unwrap();
1023        let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
1024            .into_iter()
1025            .collect();
1026        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1027        assert_eq!(eff.format, AudioFormat::Wav);
1028    }
1029
1030    #[test]
1031    fn flag_overrides_env_and_file() {
1032        let toml = r#"
1033            [accounts.alice]
1034            format = "flac"
1035        "#;
1036        let cfg = Config::from_toml(toml).unwrap();
1037        let env: HashMap<String, String> =
1038            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1039        let flags = FlagOverrides {
1040            format: Some(AudioFormat::Wav),
1041            ..Default::default()
1042        };
1043        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1044        assert_eq!(eff.format, AudioFormat::Wav);
1045    }
1046
1047    #[test]
1048    fn token_precedence() {
1049        let toml = r#"
1050            [accounts.alice]
1051            token = "file_tok"
1052        "#;
1053        let cfg = Config::from_toml(toml).unwrap();
1054
1055        // env overrides file
1056        let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
1057            .into_iter()
1058            .collect();
1059        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1060        assert_eq!(eff.token.as_deref(), Some("env_tok"));
1061        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1062
1063        // flag overrides env
1064        let flags = FlagOverrides {
1065            token: Some("flag_tok".into()),
1066            ..Default::default()
1067        };
1068        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1069        assert_eq!(eff.token.as_deref(), Some("flag_tok"));
1070        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1071    }
1072
1073    #[test]
1074    fn stored_token_is_populated_from_config_when_no_override_exists() {
1075        let toml = r#"
1076            [accounts.alice]
1077            token = "file_tok"
1078        "#;
1079        let cfg = Config::from_toml(toml).unwrap();
1080        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1081        assert_eq!(eff.token, None);
1082        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1083        assert_eq!(eff.token_command, None);
1084    }
1085
1086    #[test]
1087    fn per_account_token_env_overrides_global() {
1088        let toml = "[accounts.alice]\n";
1089        let cfg = Config::from_toml(toml).unwrap();
1090        let env: HashMap<String, String> = [
1091            ("SUNO_TOKEN".into(), "global".into()),
1092            ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
1093        ]
1094        .into_iter()
1095        .collect();
1096        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1097        assert_eq!(eff.token.as_deref(), Some("per_account"));
1098    }
1099
1100    #[test]
1101    fn token_command_resolves_from_defaults_account_source_and_env() {
1102        let toml = r#"
1103            [defaults]
1104            token_command = "defaults"
1105
1106            [accounts.alice]
1107            token_command = "account"
1108
1109            [accounts.alice.sources.liked]
1110            token_command = "source"
1111        "#;
1112        let cfg = Config::from_toml(toml).unwrap();
1113
1114        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1115        assert_eq!(eff.token_command.as_deref(), Some("account"));
1116
1117        let eff = cfg
1118            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1119            .unwrap();
1120        assert_eq!(eff.token_command.as_deref(), Some("source"));
1121
1122        let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
1123            .into_iter()
1124            .collect();
1125        let eff = cfg
1126            .resolve("alice", Some("liked"), &env, &no_flags())
1127            .unwrap();
1128        assert_eq!(eff.token_command.as_deref(), Some("global"));
1129
1130        let env: HashMap<String, String> = [
1131            ("SUNO_TOKEN_COMMAND".into(), "global".into()),
1132            ("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
1133        ]
1134        .into_iter()
1135        .collect();
1136        let eff = cfg
1137            .resolve("alice", Some("liked"), &env, &no_flags())
1138            .unwrap();
1139        assert_eq!(eff.token_command.as_deref(), Some("per_account"));
1140    }
1141
1142    #[test]
1143    fn per_account_token_command_env_label_uppersnakedcase() {
1144        let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
1145        let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
1146            .into_iter()
1147            .collect();
1148        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1149        assert_eq!(eff.token_command.as_deref(), Some("command"));
1150    }
1151
1152    #[test]
1153    fn invalid_env_u32_errors() {
1154        let toml = "[accounts.alice]\n";
1155        let cfg = Config::from_toml(toml).unwrap();
1156        let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
1157            .into_iter()
1158            .collect();
1159        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1160    }
1161
1162    #[test]
1163    fn animated_covers_defaults_off_and_follows_precedence() {
1164        // Compiled default is off.
1165        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1166        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1167        assert!(!eff.animated_covers);
1168
1169        // File default on; per-source off; env on; flag off — flag wins.
1170        let toml = r#"
1171            [defaults]
1172            animated_covers = true
1173
1174            [accounts.alice.sources.liked]
1175            animated_covers = false
1176        "#;
1177        let cfg = Config::from_toml(toml).unwrap();
1178
1179        // File default (defaults) turns it on for an unscoped resolve.
1180        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1181        assert!(eff.animated_covers);
1182
1183        // Per-source file setting overrides the file default.
1184        let eff = cfg
1185            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1186            .unwrap();
1187        assert!(!eff.animated_covers);
1188
1189        // Env overrides file (even the per-source off).
1190        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
1191            .into_iter()
1192            .collect();
1193        let eff = cfg
1194            .resolve("alice", Some("liked"), &env, &no_flags())
1195            .unwrap();
1196        assert!(eff.animated_covers);
1197
1198        // Flag overrides env.
1199        let flags = FlagOverrides {
1200            animated_covers: Some(false),
1201            ..Default::default()
1202        };
1203        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1204        assert!(!eff.animated_covers);
1205    }
1206
1207    #[test]
1208    fn video_mp4_defaults_off_and_follows_precedence() {
1209        // Compiled default is off.
1210        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1211        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1212        assert!(!eff.video_mp4);
1213
1214        // File default on; per-source off; env on; flag off — flag wins.
1215        let toml = r#"
1216            [defaults]
1217            video_mp4 = true
1218
1219            [accounts.alice.sources.liked]
1220            video_mp4 = false
1221        "#;
1222        let cfg = Config::from_toml(toml).unwrap();
1223        assert!(
1224            cfg.resolve("alice", None, &no_env(), &no_flags())
1225                .unwrap()
1226                .video_mp4
1227        );
1228        assert!(
1229            !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1230                .unwrap()
1231                .video_mp4
1232        );
1233
1234        let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
1235            .into_iter()
1236            .collect();
1237        assert!(
1238            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1239                .unwrap()
1240                .video_mp4
1241        );
1242
1243        let flags = FlagOverrides {
1244            video_mp4: Some(false),
1245            ..Default::default()
1246        };
1247        assert!(
1248            !cfg.resolve("alice", Some("liked"), &env, &flags)
1249                .unwrap()
1250                .video_mp4
1251        );
1252    }
1253
1254    #[test]
1255    fn download_stems_defaults_off_and_follows_precedence() {
1256        // Compiled default is off (bulk stem mirroring never spends, but it is
1257        // opt-in so it never runs unless asked).
1258        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1259        assert!(
1260            !cfg.resolve("alice", None, &no_env(), &no_flags())
1261                .unwrap()
1262                .download_stems
1263        );
1264
1265        // File default on; per-source off; env on; flag off — flag wins.
1266        let toml = r#"
1267            [defaults]
1268            download_stems = true
1269
1270            [accounts.alice.sources.liked]
1271            download_stems = false
1272        "#;
1273        let cfg = Config::from_toml(toml).unwrap();
1274        assert!(
1275            cfg.resolve("alice", None, &no_env(), &no_flags())
1276                .unwrap()
1277                .download_stems
1278        );
1279        assert!(
1280            !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1281                .unwrap()
1282                .download_stems
1283        );
1284
1285        let env: HashMap<String, String> = [("SUNO_DOWNLOAD_STEMS".into(), "true".into())]
1286            .into_iter()
1287            .collect();
1288        assert!(
1289            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1290                .unwrap()
1291                .download_stems
1292        );
1293
1294        let flags = FlagOverrides {
1295            download_stems: Some(false),
1296            ..Default::default()
1297        };
1298        assert!(
1299            !cfg.resolve("alice", Some("liked"), &env, &flags)
1300                .unwrap()
1301                .download_stems
1302        );
1303    }
1304
1305    #[test]
1306    fn stem_format_defaults_to_wav_and_follows_precedence() {
1307        // Compiled default is WAV (lossless, the safe default for stems).
1308        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1309        assert_eq!(
1310            cfg.resolve("alice", None, &no_env(), &no_flags())
1311                .unwrap()
1312                .stem_format,
1313            StemFormat::Wav
1314        );
1315
1316        // File default mp3; per-source wav; env mp3; flag wav — flag wins.
1317        let toml = r#"
1318            [defaults]
1319            stem_format = "mp3"
1320
1321            [accounts.alice.sources.liked]
1322            stem_format = "wav"
1323        "#;
1324        let cfg = Config::from_toml(toml).unwrap();
1325        assert_eq!(
1326            cfg.resolve("alice", None, &no_env(), &no_flags())
1327                .unwrap()
1328                .stem_format,
1329            StemFormat::Mp3
1330        );
1331        assert_eq!(
1332            cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1333                .unwrap()
1334                .stem_format,
1335            StemFormat::Wav
1336        );
1337
1338        let env: HashMap<String, String> = [("SUNO_STEM_FORMAT".into(), "mp3".into())]
1339            .into_iter()
1340            .collect();
1341        assert_eq!(
1342            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1343                .unwrap()
1344                .stem_format,
1345            StemFormat::Mp3
1346        );
1347
1348        let flags = FlagOverrides {
1349            stem_format: Some(StemFormat::Wav),
1350            ..Default::default()
1351        };
1352        assert_eq!(
1353            cfg.resolve("alice", Some("liked"), &env, &flags)
1354                .unwrap()
1355                .stem_format,
1356            StemFormat::Wav
1357        );
1358    }
1359
1360    #[test]
1361    fn stem_format_rejects_flac_and_unknown() {
1362        // FLAC is deliberately unrepresentable for stems: parsing it is an error,
1363        // so a config or flag can never ask for a FLAC stem.
1364        assert!("flac".parse::<StemFormat>().is_err());
1365        assert!("aac".parse::<StemFormat>().is_err());
1366        assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
1367        assert_eq!("Mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
1368        // A FLAC stem_format in config is a config error, not a silent fallback.
1369        assert!(Config::from_toml("[defaults]\nstem_format = \"flac\"\n").is_err());
1370    }
1371
1372    #[test]
1373    fn video_cover_retention_drives_cover_artifacts_not_the_music_video() {
1374        let resolve = |retention: &str| {
1375            let toml = format!("[accounts.alice]\nvideo_cover_retention = \"{retention}\"\n");
1376            Config::from_toml(&toml)
1377                .unwrap()
1378                .resolve("alice", None, &no_env(), &no_flags())
1379                .unwrap()
1380        };
1381
1382        let neither = resolve("neither");
1383        assert!(!neither.animated_covers && !neither.raw_animated_cover);
1384        assert_eq!(neither.video_cover_retention, VideoCoverRetention::Neither);
1385
1386        let webp = resolve("webp");
1387        assert!(webp.animated_covers && !webp.raw_animated_cover);
1388        assert_eq!(webp.video_cover_retention, VideoCoverRetention::Webp);
1389
1390        // `mp4` keeps the raw album cover (`video_cover_url` verbatim); it does
1391        // NOT switch on the standalone music-video toggle (`video_url`).
1392        let mp4 = resolve("mp4");
1393        assert!(!mp4.animated_covers && mp4.raw_animated_cover);
1394        assert!(!mp4.video_mp4);
1395        assert_eq!(mp4.video_cover_retention, VideoCoverRetention::Mp4);
1396
1397        let both = resolve("both");
1398        assert!(both.animated_covers && both.raw_animated_cover);
1399        assert!(!both.video_mp4);
1400        assert_eq!(both.video_cover_retention, VideoCoverRetention::Both);
1401    }
1402
1403    #[test]
1404    fn video_mp4_is_independent_of_cover_retention() {
1405        // The standalone music video (`video_url`) has its own toggle and is
1406        // never implied by a `video_cover_retention` mode, nor vice versa.
1407        let toml = "[accounts.alice]\nvideo_mp4 = true\nvideo_cover_retention = \"webp\"\n";
1408        let cfg = Config::from_toml(toml).unwrap();
1409        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1410        assert!(eff.video_mp4);
1411        assert!(eff.animated_covers);
1412        assert!(!eff.raw_animated_cover);
1413        assert_eq!(eff.video_cover_retention, VideoCoverRetention::Webp);
1414    }
1415
1416    #[test]
1417    fn animated_cover_webp_knobs_follow_precedence_and_validate_ranges() {
1418        let toml = r#"
1419            [defaults]
1420            animated_cover_quality = 80
1421            animated_cover_max_fps = 20
1422            animated_cover_max_width = 640
1423            animated_cover_compression_level = 3
1424
1425            [accounts.alice.sources.liked]
1426            animated_cover_quality = 75
1427        "#;
1428        let cfg = Config::from_toml(toml).unwrap();
1429        let eff = cfg
1430            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1431            .unwrap();
1432        assert_eq!(eff.animated_cover_webp.quality, 75);
1433        assert_eq!(eff.animated_cover_webp.max_fps, 20);
1434        assert_eq!(eff.animated_cover_webp.max_width, Some(640));
1435        assert_eq!(eff.animated_cover_webp.compression_level, 3);
1436
1437        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVER_QUALITY".into(), "90".into())]
1438            .into_iter()
1439            .collect();
1440        let eff = cfg
1441            .resolve("alice", Some("liked"), &env, &no_flags())
1442            .unwrap();
1443        assert_eq!(eff.animated_cover_webp.quality, 90);
1444
1445        let flags = FlagOverrides {
1446            animated_cover_quality: Some(95),
1447            animated_cover_max_width: Some(512),
1448            animated_cover_compression_level: Some(6),
1449            ..Default::default()
1450        };
1451        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1452        assert_eq!(eff.animated_cover_webp.quality, 95);
1453        assert_eq!(eff.animated_cover_webp.max_width, Some(512));
1454        assert_eq!(eff.animated_cover_webp.compression_level, 6);
1455
1456        let bad_env: HashMap<String, String> =
1457            [("SUNO_ANIMATED_COVER_QUALITY".into(), "101".into())]
1458                .into_iter()
1459                .collect();
1460        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1461    }
1462
1463    #[test]
1464    fn video_cover_retention_parses_formats_and_reports_kept_artifacts() {
1465        // FromStr is case-insensitive across every variant.
1466        assert_eq!(
1467            "NEITHER".parse::<VideoCoverRetention>().unwrap(),
1468            VideoCoverRetention::Neither
1469        );
1470        assert_eq!(
1471            "WebP".parse::<VideoCoverRetention>().unwrap(),
1472            VideoCoverRetention::Webp
1473        );
1474        assert_eq!(
1475            "mp4".parse::<VideoCoverRetention>().unwrap(),
1476            VideoCoverRetention::Mp4
1477        );
1478        assert_eq!(
1479            "Both".parse::<VideoCoverRetention>().unwrap(),
1480            VideoCoverRetention::Both
1481        );
1482        // An unknown mode is a config error, not a silent fallback.
1483        assert!("mkv".parse::<VideoCoverRetention>().is_err());
1484
1485        // Display round-trips back to a token FromStr accepts.
1486        for mode in [
1487            VideoCoverRetention::Neither,
1488            VideoCoverRetention::Webp,
1489            VideoCoverRetention::Mp4,
1490            VideoCoverRetention::Both,
1491        ] {
1492            assert_eq!(
1493                mode.to_string().parse::<VideoCoverRetention>().unwrap(),
1494                mode
1495            );
1496        }
1497
1498        // keeps_webp / keeps_mp4 truth table.
1499        assert!(!VideoCoverRetention::Neither.keeps_webp());
1500        assert!(!VideoCoverRetention::Neither.keeps_mp4());
1501        assert!(VideoCoverRetention::Webp.keeps_webp());
1502        assert!(!VideoCoverRetention::Webp.keeps_mp4());
1503        assert!(!VideoCoverRetention::Mp4.keeps_webp());
1504        assert!(VideoCoverRetention::Mp4.keeps_mp4());
1505        assert!(VideoCoverRetention::Both.keeps_webp());
1506        assert!(VideoCoverRetention::Both.keeps_mp4());
1507    }
1508
1509    #[test]
1510    fn video_cover_retention_resolves_from_env_and_rejects_unknown() {
1511        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1512
1513        // A valid env value overrides the legacy toggles, just like a flag.
1514        let env: HashMap<String, String> = [("SUNO_VIDEO_COVER_RETENTION".into(), "both".into())]
1515            .into_iter()
1516            .collect();
1517        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1518        assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1519        assert!(eff.animated_covers);
1520        assert!(eff.raw_animated_cover);
1521
1522        // An unknown env value is a config error rather than a silent default.
1523        let bad_env: HashMap<String, String> =
1524            [("SUNO_VIDEO_COVER_RETENTION".into(), "mkv".into())]
1525                .into_iter()
1526                .collect();
1527        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1528    }
1529
1530    #[test]
1531    fn animated_cover_compression_level_enforces_zero_to_six() {
1532        // The top of the valid range is accepted from the config file.
1533        let cfg = Config::from_toml(
1534            "[defaults]\nanimated_cover_compression_level = 6\n[accounts.alice]\n",
1535        )
1536        .unwrap();
1537        assert_eq!(
1538            cfg.resolve("alice", None, &no_env(), &no_flags())
1539                .unwrap()
1540                .animated_cover_webp
1541                .compression_level,
1542            6
1543        );
1544
1545        // One past the top is rejected.
1546        let cfg = Config::from_toml(
1547            "[defaults]\nanimated_cover_compression_level = 7\n[accounts.alice]\n",
1548        )
1549        .unwrap();
1550        assert!(cfg.resolve("alice", None, &no_env(), &no_flags()).is_err());
1551
1552        // The same ceiling is enforced for an env override.
1553        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1554        let bad_env: HashMap<String, String> =
1555            [("SUNO_ANIMATED_COVER_COMPRESSION_LEVEL".into(), "7".into())]
1556                .into_iter()
1557                .collect();
1558        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1559
1560        // A non-integer env value is a config error, not a panic.
1561        let junk_env: HashMap<String, String> =
1562            [("SUNO_ANIMATED_COVER_MAX_FPS".into(), "abc".into())]
1563                .into_iter()
1564                .collect();
1565        assert!(cfg.resolve("alice", None, &junk_env, &no_flags()).is_err());
1566    }
1567
1568    #[test]
1569    fn animated_cover_max_width_defaults_to_native() {
1570        // With nothing configured, the width cap is None (source width).
1571        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1572        assert_eq!(
1573            cfg.resolve("alice", None, &no_env(), &no_flags())
1574                .unwrap()
1575                .animated_cover_webp
1576                .max_width,
1577            None
1578        );
1579    }
1580
1581    #[test]
1582    fn text_sidecars_default_off_and_follow_precedence() {
1583        // Both compiled defaults are off.
1584        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1585        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1586        assert!(!eff.details_sidecar);
1587        assert!(!eff.lyrics_sidecar);
1588
1589        let toml = r#"
1590            [defaults]
1591            details_sidecar = true
1592
1593            [accounts.alice.sources.liked]
1594            details_sidecar = false
1595        "#;
1596        let cfg = Config::from_toml(toml).unwrap();
1597
1598        // File default turns details on for an unscoped resolve; lyrics stays off.
1599        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1600        assert!(eff.details_sidecar);
1601        assert!(!eff.lyrics_sidecar);
1602
1603        // Per-source file setting overrides the file default.
1604        let eff = cfg
1605            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1606            .unwrap();
1607        assert!(!eff.details_sidecar);
1608
1609        // Env overrides file (both flags), and the flag overrides env.
1610        let env: HashMap<String, String> = [
1611            ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
1612            ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
1613        ]
1614        .into_iter()
1615        .collect();
1616        let eff = cfg
1617            .resolve("alice", Some("liked"), &env, &no_flags())
1618            .unwrap();
1619        assert!(eff.details_sidecar);
1620        assert!(eff.lyrics_sidecar);
1621
1622        let flags = FlagOverrides {
1623            lyrics_sidecar: Some(false),
1624            ..Default::default()
1625        };
1626        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1627        assert!(eff.details_sidecar);
1628        assert!(!eff.lyrics_sidecar);
1629    }
1630
1631    #[test]
1632    fn invalid_env_bool_errors() {
1633        let toml = "[accounts.alice]\n";
1634        let cfg = Config::from_toml(toml).unwrap();
1635        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
1636            .into_iter()
1637            .collect();
1638        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1639    }
1640
1641    #[test]
1642    fn unknown_account_errors() {
1643        let cfg = Config::from_toml("").unwrap();
1644        assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
1645    }
1646
1647    #[test]
1648    fn validation_nested_roots() {
1649        let toml = r#"
1650            [accounts.alice]
1651            root = "/music"
1652
1653            [accounts.bob]
1654            root = "/music/bob"
1655        "#;
1656        assert!(Config::from_toml(toml).is_err());
1657    }
1658
1659    #[test]
1660    fn validation_non_nested_roots_ok() {
1661        let toml = r#"
1662            [accounts.alice]
1663            root = "/music/alice"
1664
1665            [accounts.bob]
1666            root = "/music/bob"
1667        "#;
1668        assert!(Config::from_toml(toml).is_ok());
1669    }
1670
1671    #[test]
1672    fn invalid_toml_errors() {
1673        assert!(Config::from_toml("not valid toml ][").is_err());
1674    }
1675
1676    #[test]
1677    fn duplicate_account_label_errors() {
1678        // The TOML spec prohibits duplicate keys; the parser must reject this.
1679        let toml = "
1680            [accounts.alice]
1681            token = \"tok1\"
1682
1683            [accounts.alice]
1684            token = \"tok2\"
1685        ";
1686        assert!(Config::from_toml(toml).is_err());
1687    }
1688
1689    #[test]
1690    fn parse_error_does_not_echo_token() {
1691        // A malformed token line must not include the raw value in the error.
1692        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
1693        let err = Config::from_toml(toml).unwrap_err().to_string();
1694        assert!(!err.contains("unterminated"), "error leaked token: {err}");
1695    }
1696
1697    #[test]
1698    fn validation_env_prefix_collision_errors() {
1699        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
1700        let toml = "
1701            [accounts.my-lib]
1702            [accounts.my_lib]
1703        ";
1704        assert!(Config::from_toml(toml).is_err());
1705    }
1706
1707    #[test]
1708    fn audio_format_display_roundtrip() {
1709        for fmt in [
1710            AudioFormat::Mp3,
1711            AudioFormat::Flac,
1712            AudioFormat::Wav,
1713            AudioFormat::Alac,
1714        ] {
1715            let s = fmt.to_string();
1716            assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
1717        }
1718    }
1719
1720    #[test]
1721    fn audio_format_ext() {
1722        assert_eq!(AudioFormat::Mp3.ext(), "mp3");
1723        assert_eq!(AudioFormat::Flac.ext(), "flac");
1724        assert_eq!(AudioFormat::Wav.ext(), "wav");
1725        assert_eq!(AudioFormat::Alac.ext(), "m4a");
1726    }
1727
1728    #[test]
1729    fn naming_template_follows_precedence() {
1730        let toml = r#"
1731            [defaults]
1732            naming_template = "{title}"
1733
1734            [accounts.alice]
1735            naming_template = "{creator}/{title}"
1736
1737            [accounts.alice.sources.liked]
1738            naming_template = "{handle}/{title} [{id8}]"
1739        "#;
1740        let cfg = Config::from_toml(toml).unwrap();
1741
1742        // Per-source wins over account.
1743        let eff = cfg
1744            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1745            .unwrap();
1746        assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
1747
1748        // Account wins over defaults.
1749        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1750        assert_eq!(eff.naming_template, "{creator}/{title}");
1751
1752        // Env overrides file.
1753        let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
1754            .into_iter()
1755            .collect();
1756        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1757        assert_eq!(eff.naming_template, "{id}");
1758
1759        // Flag overrides env.
1760        let flags = FlagOverrides {
1761            naming_template: Some("{title}/{id8}".into()),
1762            ..Default::default()
1763        };
1764        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1765        assert_eq!(eff.naming_template, "{title}/{id8}");
1766    }
1767
1768    #[test]
1769    fn character_set_follows_precedence() {
1770        let toml = r#"
1771            [defaults]
1772            character_set = "ascii"
1773
1774            [accounts.alice]
1775        "#;
1776        let cfg = Config::from_toml(toml).unwrap();
1777
1778        // File default applies.
1779        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1780        assert_eq!(eff.character_set, CharacterSet::Ascii);
1781
1782        // Env overrides file.
1783        let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
1784            .into_iter()
1785            .collect();
1786        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1787        assert_eq!(eff.character_set, CharacterSet::Unicode);
1788
1789        // Flag overrides env.
1790        let flags = FlagOverrides {
1791            character_set: Some(CharacterSet::Ascii),
1792            ..Default::default()
1793        };
1794        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1795        assert_eq!(eff.character_set, CharacterSet::Ascii);
1796    }
1797
1798    #[test]
1799    fn invalid_character_set_env_errors() {
1800        let toml = "[accounts.alice]\n";
1801        let cfg = Config::from_toml(toml).unwrap();
1802        let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
1803            .into_iter()
1804            .collect();
1805        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1806    }
1807
1808    #[test]
1809    fn areas_parse_full_table() {
1810        let toml = r#"
1811            [accounts.alice]
1812            token = "t"
1813            [accounts.alice.areas]
1814            library = "off"
1815            liked = "copy"
1816            playlists = "mirror"
1817            [accounts.alice.areas.playlist]
1818            "pl_abc123" = "mirror"
1819            "pl_def456" = "copy"
1820        "#;
1821        let cfg = Config::from_toml(toml).unwrap();
1822        let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
1823        assert_eq!(areas.library, Some(AreaMode::Off));
1824        assert_eq!(areas.liked, Some(SourceMode::Copy));
1825        assert_eq!(areas.playlists, Some(SourceMode::Mirror));
1826        assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
1827        assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
1828    }
1829
1830    #[test]
1831    fn album_overrides_parse_and_resolve() {
1832        let toml = r#"
1833            [accounts.alice]
1834            token = "t"
1835            [accounts.alice.albums]
1836            "root_abc123" = "Preferred Name"
1837            "root_def456" = "Another Album"
1838            "root_blank" = "   "
1839        "#;
1840        let cfg = Config::from_toml(toml).unwrap();
1841        assert_eq!(
1842            cfg.accounts["alice"].albums["root_abc123"],
1843            "Preferred Name"
1844        );
1845        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1846        assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
1847        assert_eq!(eff.album_overrides["root_def456"], "Another Album");
1848        // A blank value is dropped so it can never blank an album.
1849        assert!(!eff.album_overrides.contains_key("root_blank"));
1850    }
1851
1852    #[test]
1853    fn album_overrides_absent_by_default() {
1854        let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
1855        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1856        assert!(eff.album_overrides.is_empty());
1857    }
1858
1859    #[test]
1860    fn areas_library_accepts_copy_and_mirror() {
1861        for (raw, expect) in [
1862            ("copy", AreaMode::Mode(SourceMode::Copy)),
1863            ("mirror", AreaMode::Mode(SourceMode::Mirror)),
1864        ] {
1865            let toml =
1866                format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
1867            let cfg = Config::from_toml(&toml).unwrap();
1868            assert_eq!(
1869                cfg.accounts["a"].areas.as_ref().unwrap().library,
1870                Some(expect)
1871            );
1872        }
1873    }
1874
1875    #[test]
1876    fn areas_bad_mode_errors() {
1877        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
1878        assert!(Config::from_toml(toml).is_err());
1879    }
1880
1881    #[test]
1882    fn areas_bad_playlist_mode_errors() {
1883        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
1884        // `off` is a library-only value; a per-playlist entry must be copy/mirror.
1885        assert!(Config::from_toml(toml).is_err());
1886    }
1887
1888    #[test]
1889    fn areas_unknown_field_errors() {
1890        // D7: a mistyped key (libary) is a parse error, not a silent no-op.
1891        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
1892        assert!(Config::from_toml(toml).is_err());
1893    }
1894
1895    #[test]
1896    fn areas_absent_is_none() {
1897        let toml = "[accounts.a]\ntoken = \"t\"\n";
1898        assert!(
1899            Config::from_toml(toml).unwrap().accounts["a"]
1900                .areas
1901                .is_none()
1902        );
1903    }
1904
1905    fn base_settings(format: AudioFormat) -> EffectiveSettings {
1906        let toml = "[accounts.a]\n";
1907        let cfg = Config::from_toml(toml).unwrap();
1908        let mut eff = cfg.resolve("a", None, &no_env(), &no_flags()).unwrap();
1909        eff.format = format;
1910        eff
1911    }
1912
1913    #[test]
1914    fn requires_ffmpeg_flac_always_needs_it() {
1915        let mut eff = base_settings(AudioFormat::Flac);
1916        eff.animated_covers = false;
1917        assert!(eff.requires_ffmpeg());
1918        eff.animated_covers = true;
1919        assert!(eff.requires_ffmpeg());
1920    }
1921
1922    #[test]
1923    fn requires_ffmpeg_alac_always_needs_it() {
1924        let mut eff = base_settings(AudioFormat::Alac);
1925        eff.animated_covers = false;
1926        assert!(eff.requires_ffmpeg(), "alac transcodes, so needs ffmpeg");
1927    }
1928
1929    #[test]
1930    fn requires_ffmpeg_mp3_needs_it_only_for_animated_webp() {
1931        let mut eff = base_settings(AudioFormat::Mp3);
1932        assert!(!eff.requires_ffmpeg(), "mp3 + no covers = no ffmpeg");
1933        eff.animated_covers = true;
1934        assert!(eff.requires_ffmpeg(), "mp3 + animated webp = needs ffmpeg");
1935        // `both` retention keeps the raw mp4 AND the transcoded webp, so ffmpeg
1936        // is still required to produce the webp.
1937        eff.raw_animated_cover = true;
1938        assert!(
1939            eff.requires_ffmpeg(),
1940            "mp3 + both (webp + raw mp4) = needs ffmpeg"
1941        );
1942        eff.animated_covers = false;
1943        assert!(!eff.requires_ffmpeg(), "mp3 + raw mp4 only = no ffmpeg");
1944    }
1945}