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