Skip to main content

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