suno_core/desired.rs
1//! Pure desired-state construction: clip target entries and playlist manifests.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::path::{Component, Path};
5
6use crate::extras::{M3u8Entry, render_clip_details, render_m3u8};
7use crate::hash::{
8 art_hash, art_url_hash, content_hash, embedded_art_hash, lyrics_txt_source_hash, meta_hash,
9 synced_lrc_source_hash, webp_art_hash,
10};
11use crate::lineage::LineageContext;
12use crate::model::Clip;
13use crate::model::Stem;
14use crate::naming::{
15 CharacterSet, NamingConfig, NamingRequest, render_clip_names, sanitise_name, stem_file_path,
16};
17use crate::reconcile::{AlbumDesired, Desired, DesiredArtifact, DesiredStem, PlaylistDesired};
18use crate::vocab::{ArtifactKind, AudioFormat, SourceMode, StemFormat, WebpEncodeSettings};
19
20/// The synthetic playlist id for the liked feed, rendered as "Liked Songs".
21///
22/// Suno playlist ids are UUIDs, so this short literal never collides with a real
23/// playlist id in the store keyspace.
24pub const LIKED_PLAYLIST_ID: &str = "liked";
25
26/// The per-song sidecar toggles resolved for a run: each embeds or writes one
27/// artefact (animated WebP cover, `.details.txt`, `.lyrics.txt`, synced `.lrc`,
28/// standalone `.mp4`). All default off.
29#[derive(Debug, Clone, Copy, Default)]
30pub struct ArtifactToggles {
31 pub animated_covers: bool,
32 pub details: bool,
33 pub lyrics: bool,
34 pub lrc: bool,
35 pub video: bool,
36 /// The animated-cover encode settings, folded into the embedded-cover hash
37 /// (see `embedded_art_hash`) so a settings change re-embeds existing covers.
38 pub webp: WebpEncodeSettings,
39}
40
41/// One fetched playlist to render: its stable id, display name, and ordered
42/// member clips (already non-trashed, in Suno order).
43pub struct PlaylistInput<'a> {
44 pub id: &'a str,
45 pub name: &'a str,
46 pub members: &'a [Clip],
47}
48
49/// Build the desired target state for a union of selected clips.
50///
51/// Naming is rendered as a batch so collisions are disambiguated globally. Each
52/// clip's `modes` is stamped from `modes_by_id` — every selected area (mirror
53/// and copy alike) that holds it — so a clip in both a `Mirror` and a `Copy`
54/// carries both and copy-wins protection holds (SYNC-8). Every clip must appear
55/// in the map; an empty `modes` would silently drop that protection, so it trips
56/// a `debug_assert` (D6) and in release defaults to unprotected.
57///
58/// `contexts` supplies each clip's resolved [`LineageContext`] (album, lineage
59/// tags, change hash), falling back to a self-rooted context when absent.
60/// `colliding_albums` is the set of root titles shared by more than one root; a
61/// clip in that set is folded into a `[{root_id8}]`-suffixed folder so two roots
62/// never share one. `colliding_ids` is the file-name counterpart: the set of
63/// clip ids sharing an `{id8}` with another distinct clip archive-wide, so a
64/// clip in it gets a stable full-id suffix regardless of the batch (#356).
65/// `toggles` gates the per-song sidecars in `clip_artifacts`.
66#[allow(clippy::too_many_arguments)]
67pub fn build_desired(
68 clips: &[&Clip],
69 format: AudioFormat,
70 modes_by_id: &HashMap<String, Vec<SourceMode>>,
71 contexts: &HashMap<String, LineageContext>,
72 colliding_albums: &BTreeSet<String>,
73 colliding_ids: &BTreeSet<String>,
74 toggles: ArtifactToggles,
75 naming: &NamingConfig,
76) -> Vec<Desired> {
77 let lineages: Vec<LineageContext> = clips
78 .iter()
79 .map(|clip| {
80 contexts
81 .get(&clip.id)
82 .cloned()
83 .unwrap_or_else(|| LineageContext::own_root(clip))
84 })
85 .collect();
86 // The requests borrow `lineages`; scope them so the borrow ends before the
87 // lineages are moved into the desired entries below.
88 let names = {
89 let requests: Vec<NamingRequest<'_>> = clips
90 .iter()
91 .zip(&lineages)
92 .map(|(clip, lineage)| NamingRequest { clip, lineage })
93 .collect();
94 render_clip_names(&requests, naming, colliding_albums, colliding_ids)
95 };
96
97 clips
98 .iter()
99 .zip(names)
100 .zip(lineages)
101 .map(|((clip, name), lineage)| {
102 // The extensionless audio path; the sidecars swap the extension.
103 let base = rel_to_string(&name.relative_path);
104 let path = format!("{base}.{}", format.ext());
105 let meta_hash = meta_hash(clip, &lineage);
106 let modes = modes_by_id.get(&clip.id).cloned().unwrap_or_default();
107 // D6: empty modes would silently lose SYNC-8 copy protection.
108 debug_assert!(
109 !modes.is_empty(),
110 "clip {} has no modes in the union map",
111 clip.id
112 );
113 // Bind the artifacts before the struct literal so `&lineage` is
114 // borrowed (for the details render) before it is moved in below.
115 let artifacts = clip_artifacts(clip, &base, &lineage, toggles);
116 Desired {
117 clip: (*clip).clone(),
118 lineage,
119 path,
120 format,
121 meta_hash,
122 art_hash: embedded_art_hash(
123 clip,
124 toggles.animated_covers && format.embeds_animated_cover(),
125 &toggles.webp,
126 ),
127 // Always overwritten at the synced-lyrics resolve seam
128 // (`apply_synced_lrc`/`preview_synced_lrc`) before reconcile; the
129 // empty default only matters for this struct literal itself.
130 embedded_lyrics_hash: String::new(),
131 modes,
132 trashed: clip.is_trashed,
133 private: false,
134 artifacts,
135 // Stems are threaded in after this pure pass (they need a network
136 // listing); `None` means "no authoritative stem info", so a run
137 // without `download_stems` leaves any local stems untouched.
138 stems: None,
139 }
140 })
141 .collect()
142}
143
144/// Build the authoritative desired stem set for one clip from its listed stems.
145///
146/// Each stem file sits in the `{base}.stems/` sub-folder. Keys are the stable
147/// stem id (falling back to label, then a positional key), de-duplicated so
148/// blank or duplicate labels never collide. Stems are stored RAW (WAV or MP3),
149/// never transcoded; the rewrite hash tracks the stem's public URL. A `Wav` stem
150/// needs its own id to render, so an id-less stem falls back to `Mp3`.
151///
152/// Only ever called with an AUTHORITATIVE listing, so the result is safe to
153/// drive stem removals against.
154pub fn clip_stems(
155 base: &str,
156 stems: &[Stem],
157 stem_format: StemFormat,
158 character_set: CharacterSet,
159) -> Vec<DesiredStem> {
160 let mut seen: BTreeSet<String> = BTreeSet::new();
161 let mut out = Vec::new();
162 for (index, stem) in stems.iter().enumerate() {
163 let base_key = if !stem.id.is_empty() {
164 stem.id.clone()
165 } else if !stem.label.is_empty() {
166 stem.label.clone()
167 } else {
168 format!("stem{index}")
169 };
170 // Keep the key unique even when ids are blank and labels duplicate.
171 let mut key = base_key.clone();
172 let mut suffix = 1;
173 while !seen.insert(key.clone()) {
174 key = format!("{base_key}-{suffix}");
175 suffix += 1;
176 }
177 // Disambiguate by stable stem id when present, else the key, so two
178 // stems never map to the same file.
179 let disambiguator = if stem.id.is_empty() {
180 key.as_str()
181 } else {
182 stem.id.as_str()
183 };
184 // WAV needs the stem's own id to render; without one, store as MP3.
185 let format = if stem_format == StemFormat::Wav && stem.id.is_empty() {
186 StemFormat::Mp3
187 } else {
188 stem_format
189 };
190 let path = stem_file_path(
191 base,
192 &stem.label,
193 disambiguator,
194 format.ext(),
195 character_set,
196 );
197 out.push(DesiredStem {
198 key,
199 stem_id: stem.id.clone(),
200 path,
201 source_url: stem.url.clone(),
202 format,
203 hash: art_url_hash(&stem.url),
204 });
205 }
206 out
207}
208
209/// The per-clip sidecars desired alongside `base`, the extensionless audio path.
210///
211/// A static `CoverJpg` is emitted only when the clip has non-empty selected art;
212/// an empty art URL emits none, and reconcile reads a missing cover as
213/// UNKNOWN => KEEP, so a transient empty URL never removes an existing cover. The
214/// animated cover is not a sidecar: under `toggles.animated_covers` it is
215/// embedded as the audio file's front cover.
216///
217/// Text sidecars carry their body inline plus a `content_hash`, so an edit
218/// rewrites them even when `meta_hash` is unchanged. `DetailsTxt` is emitted
219/// whenever `toggles.details` is set, with its body inline. `LyricsTxt` and
220/// `Lrc` are both emitted for every clip under their toggle with no inline body
221/// and a stable placeholder hash, resolved just before execution from the
222/// fetched alignment (the `.lyrics.txt` from `clip.lyrics` else the aligned
223/// plain text, the `.lrc` from the timed alignment): the sung words are knowable
224/// only from the endpoint, not the v2 feed. A clip with neither alignment nor
225/// lyrics writes neither, its emptiness cached so it is not re-fetched.
226fn clip_artifacts(
227 clip: &Clip,
228 base: &str,
229 lineage: &LineageContext,
230 toggles: ArtifactToggles,
231) -> Vec<DesiredArtifact> {
232 let mut artifacts = Vec::new();
233 if let Some(url) = clip.selected_image_url().filter(|u| !u.is_empty()) {
234 artifacts.push(DesiredArtifact {
235 kind: ArtifactKind::CoverJpg,
236 path: sidecar_path(base, ArtifactKind::CoverJpg),
237 source_url: url.to_owned(),
238 hash: art_hash(clip),
239 content: None,
240 });
241 }
242 if toggles.details {
243 let text = render_clip_details(clip, lineage);
244 artifacts.push(DesiredArtifact {
245 kind: ArtifactKind::DetailsTxt,
246 path: sidecar_path(base, ArtifactKind::DetailsTxt),
247 source_url: String::new(),
248 hash: content_hash(&text),
249 content: Some(text),
250 });
251 }
252 if toggles.lyrics {
253 artifacts.push(DesiredArtifact {
254 kind: ArtifactKind::LyricsTxt,
255 path: sidecar_path(base, ArtifactKind::LyricsTxt),
256 source_url: String::new(),
257 hash: lyrics_txt_source_hash(&clip.id),
258 content: None,
259 });
260 }
261 if toggles.lrc {
262 artifacts.push(DesiredArtifact {
263 kind: ArtifactKind::Lrc,
264 path: sidecar_path(base, ArtifactKind::Lrc),
265 source_url: String::new(),
266 hash: synced_lrc_source_hash(&clip.id),
267 content: None,
268 });
269 }
270 if toggles.video && !clip.video_url.is_empty() {
271 artifacts.push(DesiredArtifact {
272 kind: ArtifactKind::VideoMp4,
273 path: sidecar_path(base, ArtifactKind::VideoMp4),
274 source_url: clip.video_url.clone(),
275 hash: art_url_hash(&clip.video_url),
276 content: None,
277 });
278 }
279 artifacts
280}
281
282/// The path of a per-clip sidecar built from the song's extensionless `base`.
283///
284/// The per-kind extension lives once on [`ArtifactKind::sidecar_suffix`] (#355),
285/// so the desired path and reconcile's stranded-sidecar relocation derive it
286/// from the same source. The `.expect` is only ever reached with a per-clip
287/// kind and fails loudly on a future miswiring, matching the codebase's
288/// existing guard style.
289#[allow(clippy::expect_used)]
290fn sidecar_path(base: &str, kind: ArtifactKind) -> String {
291 format!(
292 "{base}{}",
293 kind.sidecar_suffix()
294 .expect("per-clip sidecar kind has a suffix")
295 )
296}
297
298/// Build the desired `.m3u8` playlists for this run from the fetched playlists.
299///
300/// Each input is rendered in Suno order: every member clip id is looked up in
301/// this run's `desired` audio set for its relative path, title, and duration. A
302/// member absent from that set is emitted as a `# (not in library)` comment (an
303/// empty relative path) rather than a dangling path (HARDENING L1). The content
304/// hash covers the whole rendered body so any name, order, path, title, or
305/// duration change rewrites the file (HARDENING B1), named
306/// `<sanitised name>.m3u8` at the library root.
307///
308/// Pure: the caller does the best-effort fetching, excludes any playlist whose
309/// member fetch failed, and appends the synthetic liked feed.
310pub fn build_playlist_desired(
311 inputs: &[PlaylistInput<'_>],
312 desired: &[Desired],
313) -> Vec<PlaylistDesired> {
314 let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
315 inputs
316 .iter()
317 .map(|input| {
318 let entries: Vec<M3u8Entry<'_>> = input
319 .members
320 .iter()
321 .map(|member| match by_id.get(member.id.as_str()) {
322 Some(d) => M3u8Entry {
323 title: d.clip.title.as_str(),
324 duration_secs: d.clip.duration,
325 relative_path: d.path.as_str(),
326 },
327 None => M3u8Entry {
328 title: member.title.as_str(),
329 duration_secs: member.duration,
330 relative_path: "",
331 },
332 })
333 .collect();
334 let content = render_m3u8(input.name, &entries);
335 let hash = content_hash(&content);
336 let path = format!("{}.m3u8", sanitise_name(input.name));
337 PlaylistDesired {
338 id: input.id.to_owned(),
339 name: input.name.to_owned(),
340 path,
341 content,
342 hash,
343 }
344 })
345 .collect()
346}
347
348/// Render a relative path as a forward-slash string, dropping any non-normal
349/// component so the stored path is portable and never escapes the root.
350///
351/// The single source of truth for turning a rendered path into a stored/compared
352/// one: `/`-separated on every OS, so manifest paths, `parent_dir`, and the
353/// deletion-safety checks behave identically across platforms.
354pub(crate) fn rel_to_string(path: &Path) -> String {
355 path.components()
356 .filter_map(|component| match component {
357 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
358 _ => None,
359 })
360 .collect::<Vec<_>>()
361 .join("/")
362}
363
364/// Derive the desired folder art for every album in `desired`, grouped by the
365/// stable root id (HARDENING H2).
366///
367/// This is pure: it groups the selected clips by their resolved `root_id`, then
368/// per album chooses the folder-art sources deterministically:
369///
370/// - `folder.jpg` comes from the MOST-PLAYED art-bearing variant; ties break to
371/// the EARLIEST `created_at`, then the lexicographically smallest id. Its hash
372/// is the chosen art's content hash ([`art_hash`]), so a most-played flip to a
373/// variant sharing the same art is a no-op downstream (H1).
374/// - `cover.webp` (only when `animated_covers` is set) comes from the
375/// EARLIEST-created variant with a non-empty `video_cover_url`; ties break to
376/// the smallest id. Its hash folds in the `webp` encode settings, so changing
377/// quality/lossless/effort re-transcodes it. `None` when no variant has an
378/// animated source.
379/// - `cover.mp4` (only when `raw_cover` is set) is that same variant's
380/// `video_cover_url` kept verbatim (no transcode), so `both` yields the raw
381/// source beside its WebP re-encode. `None` when no variant has an animated
382/// source.
383///
384/// The album folder is the common parent of the album's clips' audio paths (they
385/// share `{creator}/{album}/`); `folder.jpg` lands at `{album_dir}/folder.jpg`
386/// and the animated covers at `{album_dir}/cover.webp` / `{album_dir}/cover.mp4`.
387pub fn album_desired(
388 desired: &[Desired],
389 animated_covers: bool,
390 raw_cover: bool,
391 webp: WebpEncodeSettings,
392) -> Vec<AlbumDesired> {
393 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
394 for d in desired {
395 groups
396 .entry(d.lineage.root_id.as_str())
397 .or_default()
398 .push(d);
399 }
400
401 groups
402 .into_iter()
403 .map(|(root_id, members)| {
404 let album_dir = album_dir_of(&members);
405 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
406 kind: ArtifactKind::FolderJpg,
407 path: album_child(&album_dir, "folder.jpg"),
408 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
409 hash: art_hash(&source.clip),
410 content: None,
411 });
412 let folder_webp = animated_covers
413 .then(|| folder_webp_source(&members))
414 .flatten()
415 .map(|source| DesiredArtifact {
416 kind: ArtifactKind::FolderWebp,
417 path: album_child(&album_dir, "cover.webp"),
418 source_url: source.clip.video_cover_url.clone(),
419 hash: webp_art_hash(&source.clip.video_cover_url, &webp),
420 content: None,
421 });
422 let folder_mp4 = raw_cover
423 .then(|| folder_webp_source(&members))
424 .flatten()
425 .map(|source| DesiredArtifact {
426 kind: ArtifactKind::FolderMp4,
427 path: album_child(&album_dir, "cover.mp4"),
428 source_url: source.clip.video_cover_url.clone(),
429 hash: art_url_hash(&source.clip.video_cover_url),
430 content: None,
431 });
432 AlbumDesired {
433 root_id: root_id.to_owned(),
434 folder_jpg,
435 folder_webp,
436 folder_mp4,
437 }
438 })
439 .collect()
440}
441
442/// The album folder: the common parent of the members' audio paths.
443///
444/// The album's clips share `{creator}/{album}/`, so any member's parent is the
445/// album dir; the smallest is taken so a stray differing path stays deterministic.
446fn album_dir_of(members: &[&Desired]) -> String {
447 members
448 .iter()
449 .map(|d| parent_dir(&d.path))
450 .min()
451 .unwrap_or("")
452 .to_owned()
453}
454
455/// The most-played art-bearing variant: the `folder.jpg` source.
456///
457/// Filtered to variants that carry selectable art, then the winner MAXIMISES
458/// `play_count`, breaking ties to the EARLIEST `created_at` and then the
459/// lexicographically smallest id, so selection is fully deterministic.
460fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
461 members
462 .iter()
463 .copied()
464 .filter(|d| {
465 d.clip
466 .selected_image_url()
467 .is_some_and(|url| !url.is_empty())
468 })
469 .min_by(|a, b| {
470 b.clip
471 .play_count
472 .cmp(&a.clip.play_count)
473 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
474 .then_with(|| a.clip.id.cmp(&b.clip.id))
475 })
476}
477
478/// The first-created animated variant: the `cover.webp` source.
479///
480/// Filtered to variants with a non-empty `video_cover_url`, then the winner is
481/// the EARLIEST `created_at`, tie-broken by the smallest id for determinism.
482fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
483 members
484 .iter()
485 .copied()
486 .filter(|d| !d.clip.video_cover_url.is_empty())
487 .min_by(|a, b| {
488 a.clip
489 .created_at
490 .cmp(&b.clip.created_at)
491 .then_with(|| a.clip.id.cmp(&b.clip.id))
492 })
493}
494
495/// The parent directory of a forward-slash relative path, or `""` at the root.
496fn parent_dir(path: &str) -> &str {
497 match path.rsplit_once('/') {
498 Some((dir, _)) => dir,
499 None => "",
500 }
501}
502
503/// Join an album dir and a file name with a forward slash, tolerating an empty
504/// dir (a path at the account root).
505fn album_child(album_dir: &str, name: &str) -> String {
506 if album_dir.is_empty() {
507 name.to_owned()
508 } else {
509 format!("{album_dir}/{name}")
510 }
511}
512
513#[cfg(test)]
514mod tests;