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, 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                modes,
124                trashed: clip.is_trashed,
125                private: false,
126                artifacts,
127                // Stems are threaded in after this pure pass (they need a network
128                // listing); `None` means "no authoritative stem info", so a run
129                // without `download_stems` leaves any local stems untouched.
130                stems: None,
131            }
132        })
133        .collect()
134}
135
136/// Build the authoritative desired stem set for one clip from its listed stems.
137///
138/// Each stem file sits in the `{base}.stems/` sub-folder. Keys are the stable
139/// stem id (falling back to label, then a positional key), de-duplicated so
140/// blank or duplicate labels never collide. Stems are stored RAW (WAV or MP3),
141/// never transcoded; the rewrite hash tracks the stem's public URL. A `Wav` stem
142/// needs its own id to render, so an id-less stem falls back to `Mp3`.
143///
144/// Only ever called with an AUTHORITATIVE listing, so the result is safe to
145/// drive stem removals against.
146pub fn clip_stems(
147    base: &str,
148    stems: &[Stem],
149    stem_format: StemFormat,
150    character_set: CharacterSet,
151) -> Vec<DesiredStem> {
152    let mut seen: BTreeSet<String> = BTreeSet::new();
153    let mut out = Vec::new();
154    for (index, stem) in stems.iter().enumerate() {
155        let base_key = if !stem.id.is_empty() {
156            stem.id.clone()
157        } else if !stem.label.is_empty() {
158            stem.label.clone()
159        } else {
160            format!("stem{index}")
161        };
162        // Keep the key unique even when ids are blank and labels duplicate.
163        let mut key = base_key.clone();
164        let mut suffix = 1;
165        while !seen.insert(key.clone()) {
166            key = format!("{base_key}-{suffix}");
167            suffix += 1;
168        }
169        // Disambiguate by stable stem id when present, else the key, so two
170        // stems never map to the same file.
171        let disambiguator = if stem.id.is_empty() {
172            key.as_str()
173        } else {
174            stem.id.as_str()
175        };
176        // WAV needs the stem's own id to render; without one, store as MP3.
177        let format = if stem_format == StemFormat::Wav && stem.id.is_empty() {
178            StemFormat::Mp3
179        } else {
180            stem_format
181        };
182        let path = stem_file_path(
183            base,
184            &stem.label,
185            disambiguator,
186            format.ext(),
187            character_set,
188        );
189        out.push(DesiredStem {
190            key,
191            stem_id: stem.id.clone(),
192            path,
193            source_url: stem.url.clone(),
194            format,
195            hash: art_url_hash(&stem.url),
196        });
197    }
198    out
199}
200
201/// The per-clip sidecars desired alongside `base`, the extensionless audio path.
202///
203/// A static `CoverJpg` is emitted only when the clip has non-empty selected art;
204/// an empty art URL emits none, and reconcile reads a missing cover as
205/// UNKNOWN => KEEP, so a transient empty URL never removes an existing cover. The
206/// animated cover is not a sidecar: under `toggles.animated_covers` it is
207/// embedded as the audio file's front cover.
208///
209/// Text sidecars carry their body inline plus a `content_hash`, so an edit
210/// rewrites them even when `meta_hash` is unchanged. `DetailsTxt` is emitted
211/// whenever `toggles.details` is set; `LyricsTxt` only when the clip has lyrics,
212/// so no empty file is written. `Lrc` is emitted for every clip under
213/// `toggles.lrc` (alignment is knowable only from the endpoint, not the feed)
214/// with no inline body, resolved just before execution; a clip with neither
215/// alignment nor lyrics writes nothing, its emptiness cached so it is not
216/// re-fetched.
217fn clip_artifacts(
218    clip: &Clip,
219    base: &str,
220    lineage: &LineageContext,
221    toggles: ArtifactToggles,
222) -> Vec<DesiredArtifact> {
223    let mut artifacts = Vec::new();
224    if let Some(url) = clip.selected_image_url().filter(|u| !u.is_empty()) {
225        artifacts.push(DesiredArtifact {
226            kind: ArtifactKind::CoverJpg,
227            path: sidecar_path(base, ArtifactKind::CoverJpg),
228            source_url: url.to_owned(),
229            hash: art_hash(clip),
230            content: None,
231        });
232    }
233    if toggles.details {
234        let text = render_clip_details(clip, lineage);
235        artifacts.push(DesiredArtifact {
236            kind: ArtifactKind::DetailsTxt,
237            path: sidecar_path(base, ArtifactKind::DetailsTxt),
238            source_url: String::new(),
239            hash: content_hash(&text),
240            content: Some(text),
241        });
242    }
243    if toggles.lyrics
244        && let Some(text) = render_clip_lyrics(clip)
245    {
246        artifacts.push(DesiredArtifact {
247            kind: ArtifactKind::LyricsTxt,
248            path: sidecar_path(base, ArtifactKind::LyricsTxt),
249            source_url: String::new(),
250            hash: content_hash(&text),
251            content: Some(text),
252        });
253    }
254    if toggles.lrc {
255        artifacts.push(DesiredArtifact {
256            kind: ArtifactKind::Lrc,
257            path: sidecar_path(base, ArtifactKind::Lrc),
258            source_url: String::new(),
259            hash: synced_lrc_source_hash(&clip.id),
260            content: None,
261        });
262    }
263    if toggles.video && !clip.video_url.is_empty() {
264        artifacts.push(DesiredArtifact {
265            kind: ArtifactKind::VideoMp4,
266            path: sidecar_path(base, ArtifactKind::VideoMp4),
267            source_url: clip.video_url.clone(),
268            hash: art_url_hash(&clip.video_url),
269            content: None,
270        });
271    }
272    artifacts
273}
274
275/// The path of a per-clip sidecar built from the song's extensionless `base`.
276///
277/// The per-kind extension lives once on [`ArtifactKind::sidecar_suffix`] (#355),
278/// so the desired path and reconcile's stranded-sidecar relocation derive it
279/// from the same source. The `.expect` is only ever reached with a per-clip
280/// kind and fails loudly on a future miswiring, matching the codebase's
281/// existing guard style.
282fn sidecar_path(base: &str, kind: ArtifactKind) -> String {
283    format!(
284        "{base}{}",
285        kind.sidecar_suffix()
286            .expect("per-clip sidecar kind has a suffix")
287    )
288}
289
290/// Build the desired `.m3u8` playlists for this run from the fetched playlists.
291///
292/// Each input is rendered in Suno order: every member clip id is looked up in
293/// this run's `desired` audio set for its relative path, title, and duration. A
294/// member absent from that set is emitted as a `# (not in library)` comment (an
295/// empty relative path) rather than a dangling path (HARDENING L1). The content
296/// hash covers the whole rendered body so any name, order, path, title, or
297/// duration change rewrites the file (HARDENING B1), named
298/// `<sanitised name>.m3u8` at the library root.
299///
300/// Pure: the caller does the best-effort fetching, excludes any playlist whose
301/// member fetch failed, and appends the synthetic liked feed.
302pub fn build_playlist_desired(
303    inputs: &[PlaylistInput<'_>],
304    desired: &[Desired],
305) -> Vec<PlaylistDesired> {
306    let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
307    inputs
308        .iter()
309        .map(|input| {
310            let entries: Vec<M3u8Entry<'_>> = input
311                .members
312                .iter()
313                .map(|member| match by_id.get(member.id.as_str()) {
314                    Some(d) => M3u8Entry {
315                        title: d.clip.title.as_str(),
316                        duration_secs: d.clip.duration,
317                        relative_path: d.path.as_str(),
318                    },
319                    None => M3u8Entry {
320                        title: member.title.as_str(),
321                        duration_secs: member.duration,
322                        relative_path: "",
323                    },
324                })
325                .collect();
326            let content = render_m3u8(input.name, &entries);
327            let hash = content_hash(&content);
328            let path = format!("{}.m3u8", sanitise_name(input.name));
329            PlaylistDesired {
330                id: input.id.to_owned(),
331                name: input.name.to_owned(),
332                path,
333                content,
334                hash,
335            }
336        })
337        .collect()
338}
339
340/// Render a relative path as a forward-slash string, dropping any non-normal
341/// component so the stored path is portable and never escapes the root.
342///
343/// The single source of truth for turning a rendered path into a stored/compared
344/// one: `/`-separated on every OS, so manifest paths, `parent_dir`, and the
345/// deletion-safety checks behave identically across platforms.
346pub(crate) fn rel_to_string(path: &Path) -> String {
347    path.components()
348        .filter_map(|component| match component {
349            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
350            _ => None,
351        })
352        .collect::<Vec<_>>()
353        .join("/")
354}
355
356/// Derive the desired folder art for every album in `desired`, grouped by the
357/// stable root id (HARDENING H2).
358///
359/// This is pure: it groups the selected clips by their resolved `root_id`, then
360/// per album chooses the folder-art sources deterministically:
361///
362/// - `folder.jpg` comes from the MOST-PLAYED art-bearing variant; ties break to
363///   the EARLIEST `created_at`, then the lexicographically smallest id. Its hash
364///   is the chosen art's content hash ([`art_hash`]), so a most-played flip to a
365///   variant sharing the same art is a no-op downstream (H1).
366/// - `cover.webp` (only when `animated_covers` is set) comes from the
367///   EARLIEST-created variant with a non-empty `video_cover_url`; ties break to
368///   the smallest id. Its hash folds in the `webp` encode settings, so changing
369///   quality/lossless/effort re-transcodes it. `None` when no variant has an
370///   animated source.
371/// - `cover.mp4` (only when `raw_cover` is set) is that same variant's
372///   `video_cover_url` kept verbatim (no transcode), so `both` yields the raw
373///   source beside its WebP re-encode. `None` when no variant has an animated
374///   source.
375///
376/// The album folder is the common parent of the album's clips' audio paths (they
377/// share `{creator}/{album}/`); `folder.jpg` lands at `{album_dir}/folder.jpg`
378/// and the animated covers at `{album_dir}/cover.webp` / `{album_dir}/cover.mp4`.
379pub fn album_desired(
380    desired: &[Desired],
381    animated_covers: bool,
382    raw_cover: bool,
383    webp: WebpEncodeSettings,
384) -> Vec<AlbumDesired> {
385    let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
386    for d in desired {
387        groups
388            .entry(d.lineage.root_id.as_str())
389            .or_default()
390            .push(d);
391    }
392
393    groups
394        .into_iter()
395        .map(|(root_id, members)| {
396            let album_dir = album_dir_of(&members);
397            let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
398                kind: ArtifactKind::FolderJpg,
399                path: album_child(&album_dir, "folder.jpg"),
400                source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
401                hash: art_hash(&source.clip),
402                content: None,
403            });
404            let folder_webp = animated_covers
405                .then(|| folder_webp_source(&members))
406                .flatten()
407                .map(|source| DesiredArtifact {
408                    kind: ArtifactKind::FolderWebp,
409                    path: album_child(&album_dir, "cover.webp"),
410                    source_url: source.clip.video_cover_url.clone(),
411                    hash: webp_art_hash(&source.clip.video_cover_url, &webp),
412                    content: None,
413                });
414            let folder_mp4 = raw_cover
415                .then(|| folder_webp_source(&members))
416                .flatten()
417                .map(|source| DesiredArtifact {
418                    kind: ArtifactKind::FolderMp4,
419                    path: album_child(&album_dir, "cover.mp4"),
420                    source_url: source.clip.video_cover_url.clone(),
421                    hash: art_url_hash(&source.clip.video_cover_url),
422                    content: None,
423                });
424            AlbumDesired {
425                root_id: root_id.to_owned(),
426                folder_jpg,
427                folder_webp,
428                folder_mp4,
429            }
430        })
431        .collect()
432}
433
434/// The album folder: the common parent of the members' audio paths.
435///
436/// The album's clips share `{creator}/{album}/`, so any member's parent is the
437/// album dir; the smallest is taken so a stray differing path stays deterministic.
438fn album_dir_of(members: &[&Desired]) -> String {
439    members
440        .iter()
441        .map(|d| parent_dir(&d.path))
442        .min()
443        .unwrap_or("")
444        .to_owned()
445}
446
447/// The most-played art-bearing variant: the `folder.jpg` source.
448///
449/// Filtered to variants that carry selectable art, then the winner MAXIMISES
450/// `play_count`, breaking ties to the EARLIEST `created_at` and then the
451/// lexicographically smallest id, so selection is fully deterministic.
452fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
453    members
454        .iter()
455        .copied()
456        .filter(|d| {
457            d.clip
458                .selected_image_url()
459                .is_some_and(|url| !url.is_empty())
460        })
461        .min_by(|a, b| {
462            b.clip
463                .play_count
464                .cmp(&a.clip.play_count)
465                .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
466                .then_with(|| a.clip.id.cmp(&b.clip.id))
467        })
468}
469
470/// The first-created animated variant: the `cover.webp` source.
471///
472/// Filtered to variants with a non-empty `video_cover_url`, then the winner is
473/// the EARLIEST `created_at`, tie-broken by the smallest id for determinism.
474fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
475    members
476        .iter()
477        .copied()
478        .filter(|d| !d.clip.video_cover_url.is_empty())
479        .min_by(|a, b| {
480            a.clip
481                .created_at
482                .cmp(&b.clip.created_at)
483                .then_with(|| a.clip.id.cmp(&b.clip.id))
484        })
485}
486
487/// The parent directory of a forward-slash relative path, or `""` at the root.
488fn parent_dir(path: &str) -> &str {
489    match path.rsplit_once('/') {
490        Some((dir, _)) => dir,
491        None => "",
492    }
493}
494
495/// Join an album dir and a file name with a forward slash, tolerating an empty
496/// dir (a path at the account root).
497fn album_child(album_dir: &str, name: &str) -> String {
498    if album_dir.is_empty() {
499        name.to_owned()
500    } else {
501        format!("{album_dir}/{name}")
502    }
503}
504
505#[cfg(test)]
506mod tests;