1use std::collections::{BTreeSet, HashMap};
6use std::str::FromStr;
7
8use crate::error::{Error, Result};
9use crate::naming::CharacterSet;
10use crate::vocab::{AudioFormat, VideoCoverRetention, WebpEncodeSettings};
11
12use super::effective::{EffectiveSettings, FlagOverrides};
13use super::label_to_env;
14use super::shape::Config;
15
16impl Config {
17 pub fn resolve(
23 &self,
24 account: &str,
25 source: Option<&str>,
26 env: &HashMap<String, String>,
27 flags: &FlagOverrides,
28 ) -> Result<EffectiveSettings> {
29 let acc = self
30 .accounts
31 .get(account)
32 .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
33
34 let src = source.and_then(|s| acc.sources.get(s));
35 let label_env = label_to_env(account);
36
37 let env_val = |suffix: &str| -> Option<&str> {
39 env.get(&format!("SUNO_{label_env}_{suffix}"))
40 .or_else(|| env.get(&format!("SUNO_{suffix}")))
41 .map(String::as_str)
42 };
43
44 let format = resolve_enum(
45 flags.settings.format,
46 env_val("FORMAT"),
47 src.and_then(|s| s.settings.format),
48 acc.settings.format,
49 self.defaults.settings.format,
50 None,
51 "FORMAT",
52 )?
53 .unwrap_or(AudioFormat::Flac);
54
55 let concurrency = resolve_parsed(
56 flags.settings.concurrency,
57 env_val("CONCURRENCY"),
58 src.and_then(|s| s.settings.concurrency),
59 acc.settings.concurrency,
60 self.defaults.settings.concurrency,
61 4,
62 "CONCURRENCY",
63 )?;
64
65 let retries = resolve_parsed(
66 flags.settings.retries,
67 env_val("RETRIES"),
68 src.and_then(|s| s.settings.retries),
69 acc.settings.retries,
70 self.defaults.settings.retries,
71 3,
72 "RETRIES",
73 )?;
74
75 let min_newest = resolve_parsed(
76 flags.settings.min_newest,
77 env_val("MIN_NEWEST"),
78 src.and_then(|s| s.settings.min_newest),
79 acc.settings.min_newest,
80 self.defaults.settings.min_newest,
81 1,
82 "MIN_NEWEST",
83 )?;
84
85 let animated_covers = resolve_parsed(
86 flags.settings.animated_covers,
87 env_val("ANIMATED_COVERS"),
88 src.and_then(|s| s.settings.animated_covers),
89 acc.settings.animated_covers,
90 self.defaults.settings.animated_covers,
91 false,
92 "ANIMATED_COVERS",
93 )?;
94
95 let details_sidecar = resolve_parsed(
96 flags.settings.details_sidecar,
97 env_val("DETAILS_SIDECAR"),
98 src.and_then(|s| s.settings.details_sidecar),
99 acc.settings.details_sidecar,
100 self.defaults.settings.details_sidecar,
101 false,
102 "DETAILS_SIDECAR",
103 )?;
104
105 let lyrics_sidecar = resolve_parsed(
106 flags.settings.lyrics_sidecar,
107 env_val("LYRICS_SIDECAR"),
108 src.and_then(|s| s.settings.lyrics_sidecar),
109 acc.settings.lyrics_sidecar,
110 self.defaults.settings.lyrics_sidecar,
111 false,
112 "LYRICS_SIDECAR",
113 )?;
114
115 let lrc_sidecar = resolve_parsed(
116 flags.settings.lrc_sidecar,
117 env_val("LRC_SIDECAR"),
118 src.and_then(|s| s.settings.lrc_sidecar),
119 acc.settings.lrc_sidecar,
120 self.defaults.settings.lrc_sidecar,
121 false,
122 "LRC_SIDECAR",
123 )?;
124
125 let video_mp4 = resolve_parsed(
126 flags.settings.video_mp4,
127 env_val("VIDEO_MP4"),
128 src.and_then(|s| s.settings.video_mp4),
129 acc.settings.video_mp4,
130 self.defaults.settings.video_mp4,
131 false,
132 "VIDEO_MP4",
133 )?;
134
135 let download_stems = resolve_parsed(
136 flags.settings.download_stems,
137 env_val("DOWNLOAD_STEMS"),
138 src.and_then(|s| s.settings.download_stems),
139 acc.settings.download_stems,
140 self.defaults.settings.download_stems,
141 false,
142 "DOWNLOAD_STEMS",
143 )?;
144
145 let stem_format = resolve_enum(
146 flags.settings.stem_format,
147 env_val("STEM_FORMAT"),
148 src.and_then(|s| s.settings.stem_format),
149 acc.settings.stem_format,
150 self.defaults.settings.stem_format,
151 None,
152 "STEM_FORMAT",
153 )?
154 .unwrap_or_default();
155
156 let video_cover_retention = resolve_enum(
157 flags.settings.video_cover_retention,
158 env_val("VIDEO_COVER_RETENTION"),
159 src.and_then(|s| s.settings.video_cover_retention),
160 acc.settings.video_cover_retention,
161 self.defaults.settings.video_cover_retention,
162 None,
163 "VIDEO_COVER_RETENTION",
164 )?;
165 let (animated_covers, raw_animated_cover) = match video_cover_retention {
170 Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
171 None => (animated_covers, false),
172 };
173
174 let defaults_webp = WebpEncodeSettings::default();
175 let animated_cover_quality = resolve_u8_ranged(
176 flags.settings.animated_cover_quality,
177 env_val("ANIMATED_COVER_QUALITY"),
178 src.and_then(|s| s.settings.animated_cover_quality),
179 acc.settings.animated_cover_quality,
180 self.defaults.settings.animated_cover_quality,
181 defaults_webp.quality,
182 "ANIMATED_COVER_QUALITY",
183 0..=100,
184 )?;
185 let animated_cover_max_fps = resolve_parsed(
186 flags.settings.animated_cover_max_fps,
187 env_val("ANIMATED_COVER_MAX_FPS"),
188 src.and_then(|s| s.settings.animated_cover_max_fps),
189 acc.settings.animated_cover_max_fps,
190 self.defaults.settings.animated_cover_max_fps,
191 defaults_webp.max_fps,
192 "ANIMATED_COVER_MAX_FPS",
193 )?;
194 let animated_cover_max_width = resolve_parsed_opt(
195 flags.settings.animated_cover_max_width,
196 env_val("ANIMATED_COVER_MAX_WIDTH"),
197 src.and_then(|s| s.settings.animated_cover_max_width),
198 acc.settings.animated_cover_max_width,
199 self.defaults.settings.animated_cover_max_width,
200 defaults_webp.max_width,
201 "ANIMATED_COVER_MAX_WIDTH",
202 )?;
203 let animated_cover_compression_level = resolve_u8_ranged(
204 flags.settings.animated_cover_compression_level,
205 env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
206 src.and_then(|s| s.settings.animated_cover_compression_level),
207 acc.settings.animated_cover_compression_level,
208 self.defaults.settings.animated_cover_compression_level,
209 defaults_webp.compression_level,
210 "ANIMATED_COVER_COMPRESSION_LEVEL",
211 0..=4,
212 )?;
213 let animated_cover_lossless = resolve_parsed(
214 flags.settings.animated_cover_lossless,
215 env_val("ANIMATED_COVER_LOSSLESS"),
216 src.and_then(|s| s.settings.animated_cover_lossless),
217 acc.settings.animated_cover_lossless,
218 self.defaults.settings.animated_cover_lossless,
219 defaults_webp.lossless,
220 "ANIMATED_COVER_LOSSLESS",
221 )?;
222
223 let naming_template = resolve_owned(
224 flags.settings.naming_template.clone(),
225 env_val("NAMING_TEMPLATE"),
226 src.and_then(|s| s.settings.naming_template.clone()),
227 acc.settings.naming_template.clone(),
228 self.defaults.settings.naming_template.clone(),
229 )
230 .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
231
232 let character_set = resolve_enum(
233 flags.settings.character_set,
234 env_val("CHARACTER_SET"),
235 src.and_then(|s| s.settings.character_set),
236 acc.settings.character_set,
237 self.defaults.settings.character_set,
238 None,
239 "CHARACTER_SET",
240 )?
241 .unwrap_or(CharacterSet::Unicode);
242
243 let number_singletons = resolve_parsed(
244 flags.settings.number_singletons,
245 env_val("NUMBER_SINGLETONS"),
246 src.and_then(|s| s.settings.number_singletons),
247 acc.settings.number_singletons,
248 self.defaults.settings.number_singletons,
249 true,
250 "NUMBER_SINGLETONS",
251 )?;
252
253 let token = flags
254 .token
255 .clone()
256 .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
257 .or_else(|| env.get("SUNO_TOKEN").cloned());
258
259 let token_command = resolve_owned(
260 None,
261 env_val("TOKEN_COMMAND"),
262 src.and_then(|s| s.settings.token_command.clone()),
263 acc.settings.token_command.clone(),
264 self.defaults.settings.token_command.clone(),
265 );
266
267 Ok(EffectiveSettings {
268 token,
269 stored_token: acc.token.clone(),
270 token_command,
271 account_id: acc.account_id.clone(),
272 format,
273 concurrency,
274 retries,
275 min_newest,
276 animated_covers,
277 raw_animated_cover,
278 video_cover_retention: match (animated_covers, raw_animated_cover) {
279 (false, false) => VideoCoverRetention::Neither,
280 (true, false) => VideoCoverRetention::Webp,
281 (false, true) => VideoCoverRetention::Mp4,
282 (true, true) => VideoCoverRetention::Both,
283 },
284 animated_cover_webp: WebpEncodeSettings {
285 quality: animated_cover_quality,
286 max_fps: animated_cover_max_fps,
287 max_width: animated_cover_max_width,
288 lossless: animated_cover_lossless,
289 compression_level: animated_cover_compression_level,
290 },
291 details_sidecar,
292 lyrics_sidecar,
293 lrc_sidecar,
294 video_mp4,
295 download_stems,
296 stem_format,
297 naming_template,
298 character_set,
299 areas: acc.areas.clone(),
300 album_overrides: acc
301 .albums
302 .iter()
303 .filter(|(_, name)| !name.trim().is_empty())
304 .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
305 .collect(),
306 lead_tracks: acc
307 .lead_tracks
308 .iter()
309 .map(|entry| entry.trim())
310 .filter(|entry| !entry.is_empty())
311 .map(str::to_owned)
312 .collect::<BTreeSet<String>>()
313 .into_iter()
314 .collect(),
315 number_singletons,
316 })
317 }
318}
319
320fn resolve_parsed<T>(
321 flag: Option<T>,
322 env_str: Option<&str>,
323 src: Option<T>,
324 acc: Option<T>,
325 defaults: Option<T>,
326 compiled: T,
327 name: &str,
328) -> Result<T>
329where
330 T: FromStr + Copy,
331{
332 Ok(
333 resolve_parsed_opt(flag, env_str, src, acc, defaults, Some(compiled), name)?
334 .unwrap_or(compiled),
335 )
336}
337
338fn resolve_parsed_opt<T>(
343 flag: Option<T>,
344 env_str: Option<&str>,
345 src: Option<T>,
346 acc: Option<T>,
347 defaults: Option<T>,
348 compiled: Option<T>,
349 name: &str,
350) -> Result<Option<T>>
351where
352 T: FromStr + Copy,
353{
354 if let Some(v) = flag {
355 return Ok(Some(v));
356 }
357 if let Some(s) = env_str {
358 return s
359 .parse()
360 .map(Some)
361 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
362 }
363 Ok(src.or(acc).or(defaults).or(compiled))
364}
365
366#[allow(clippy::too_many_arguments)]
367fn resolve_u8_ranged(
368 flag: Option<u8>,
369 env_str: Option<&str>,
370 src: Option<u8>,
371 acc: Option<u8>,
372 defaults: Option<u8>,
373 compiled: u8,
374 name: &str,
375 range: std::ops::RangeInclusive<u8>,
376) -> Result<u8> {
377 let value = if let Some(v) = flag {
378 v
379 } else if let Some(s) = env_str {
380 s.parse()
381 .map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
382 } else {
383 src.or(acc).or(defaults).unwrap_or(compiled)
384 };
385 if range.contains(&value) {
386 Ok(value)
387 } else {
388 Err(Error::Config(format!(
389 "invalid {name}: '{value}' (expected {}..={})",
390 range.start(),
391 range.end()
392 )))
393 }
394}
395
396fn resolve_enum<T>(
397 flag: Option<T>,
398 env_str: Option<&str>,
399 src: Option<T>,
400 acc: Option<T>,
401 defaults: Option<T>,
402 compiled: Option<T>,
403 name: &str,
404) -> Result<Option<T>>
405where
406 T: FromStr<Err = Error> + Copy,
407{
408 if let Some(v) = flag {
409 return Ok(Some(v));
410 }
411 if let Some(s) = env_str {
412 return s
413 .parse()
414 .map(Some)
415 .map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
416 }
417 Ok(src.or(acc).or(defaults).or(compiled))
418}
419
420fn resolve_owned(
426 flag: Option<String>,
427 env_str: Option<&str>,
428 src: Option<String>,
429 acc: Option<String>,
430 defaults: Option<String>,
431) -> Option<String> {
432 flag.or_else(|| env_str.map(str::to_owned))
433 .or(src)
434 .or(acc)
435 .or(defaults)
436}
437
438#[cfg(test)]
439mod tests;