suno_core/desired.rs
1//! Pure desired-state construction: clip target entries and playlist manifests.
2
3use std::collections::{BTreeSet, HashMap};
4use std::path::{Component, Path};
5
6use crate::extras::{M3u8Entry, render_clip_details, render_clip_lyrics, render_m3u8};
7use crate::hash::{
8 art_hash, art_url_hash, content_hash, embedded_art_hash, meta_hash, synced_lrc_source_hash,
9};
10use crate::lineage::LineageContext;
11use crate::model::Clip;
12use crate::model::Stem;
13use crate::naming::{
14 CharacterSet, NamingConfig, NamingRequest, render_clip_names, sanitise_name, stem_file_path,
15};
16use crate::reconcile::{Desired, DesiredArtifact, DesiredStem, PlaylistDesired};
17use crate::vocab::{ArtifactKind, AudioFormat, SourceMode, StemFormat, WebpEncodeSettings};
18
19/// The synthetic playlist id for the liked feed, rendered as "Liked Songs".
20///
21/// Suno playlist ids are UUIDs, so this short literal never collides with a real
22/// playlist id in the store keyspace.
23pub const LIKED_PLAYLIST_ID: &str = "liked";
24
25/// The per-song sidecar toggles resolved for a run: each embeds or writes one
26/// artefact (animated WebP cover, `.details.txt`, `.lyrics.txt`, synced `.lrc`,
27/// standalone `.mp4`). All default off.
28#[derive(Debug, Clone, Copy, Default)]
29pub struct ArtifactToggles {
30 pub animated_covers: bool,
31 pub details: bool,
32 pub lyrics: bool,
33 pub lrc: bool,
34 pub video: bool,
35 /// The animated-cover encode settings, folded into the embedded-cover hash
36 /// (see [`embedded_art_hash`]) so a settings change re-embeds existing covers.
37 pub webp: WebpEncodeSettings,
38}
39
40/// One fetched playlist to render: its stable id, display name, and ordered
41/// member clips (already non-trashed, in Suno order).
42pub struct PlaylistInput<'a> {
43 pub id: &'a str,
44 pub name: &'a str,
45 pub members: &'a [Clip],
46}
47
48/// Build the desired target state for a union of selected clips.
49///
50/// Naming is rendered as a batch so collisions are disambiguated globally. Each
51/// clip's `modes` is stamped from `modes_by_id` — every selected area (mirror
52/// and copy alike) that holds it — so a clip in both a `Mirror` and a `Copy`
53/// carries both and copy-wins protection holds (SYNC-8). Every clip must appear
54/// in the map; an empty `modes` would silently drop that protection, so it trips
55/// a `debug_assert` (D6) and in release defaults to unprotected.
56///
57/// `contexts` supplies each clip's resolved [`LineageContext`] (album, lineage
58/// tags, change hash), falling back to a self-rooted context when absent.
59/// `colliding_albums` is the set of root titles shared by more than one root; a
60/// clip in that set is folded into a `[{root_id8}]`-suffixed folder so two roots
61/// never share one. `toggles` gates the per-song sidecars in [`clip_artifacts`].
62pub fn build_desired(
63 clips: &[&Clip],
64 format: AudioFormat,
65 modes_by_id: &HashMap<String, Vec<SourceMode>>,
66 contexts: &HashMap<String, LineageContext>,
67 colliding_albums: &BTreeSet<String>,
68 toggles: ArtifactToggles,
69 naming: &NamingConfig,
70) -> Vec<Desired> {
71 let lineages: Vec<LineageContext> = clips
72 .iter()
73 .map(|clip| {
74 contexts
75 .get(&clip.id)
76 .cloned()
77 .unwrap_or_else(|| LineageContext::own_root(clip))
78 })
79 .collect();
80 // The requests borrow `lineages`; scope them so the borrow ends before the
81 // lineages are moved into the desired entries below.
82 let names = {
83 let requests: Vec<NamingRequest<'_>> = clips
84 .iter()
85 .zip(&lineages)
86 .map(|(clip, lineage)| NamingRequest { clip, lineage })
87 .collect();
88 render_clip_names(&requests, naming, colliding_albums)
89 };
90
91 clips
92 .iter()
93 .zip(names)
94 .zip(lineages)
95 .map(|((clip, name), lineage)| {
96 // The extensionless audio path; the sidecars swap the extension.
97 let base = rel_to_string(&name.relative_path);
98 let path = format!("{base}.{}", format.ext());
99 let meta_hash = meta_hash(clip, &lineage);
100 let modes = modes_by_id.get(&clip.id).cloned().unwrap_or_default();
101 // D6: empty modes would silently lose SYNC-8 copy protection.
102 debug_assert!(
103 !modes.is_empty(),
104 "clip {} has no modes in the union map",
105 clip.id
106 );
107 // Bind the artifacts before the struct literal so `&lineage` is
108 // borrowed (for the details render) before it is moved in below.
109 let artifacts = clip_artifacts(clip, &base, &lineage, toggles);
110 Desired {
111 clip: (*clip).clone(),
112 lineage,
113 path,
114 format,
115 meta_hash,
116 art_hash: embedded_art_hash(
117 clip,
118 toggles.animated_covers && format.embeds_animated_cover(),
119 &toggles.webp,
120 ),
121 modes,
122 trashed: clip.is_trashed,
123 private: false,
124 artifacts,
125 // Stems are threaded in after this pure pass (they need a network
126 // listing); `None` means "no authoritative stem info", so a run
127 // without `download_stems` leaves any local stems untouched.
128 stems: None,
129 }
130 })
131 .collect()
132}
133
134/// Build the authoritative desired stem set for one clip from its listed stems.
135///
136/// Each stem file sits in the `{base}.stems/` sub-folder. Keys are the stable
137/// stem id (falling back to label, then a positional key), de-duplicated so
138/// blank or duplicate labels never collide. Stems are stored RAW (WAV or MP3),
139/// never transcoded; the rewrite hash tracks the stem's public URL. A `Wav` stem
140/// needs its own id to render, so an id-less stem falls back to `Mp3`.
141///
142/// Only ever called with an AUTHORITATIVE listing, so the result is safe to
143/// drive stem removals against.
144pub fn clip_stems(
145 base: &str,
146 stems: &[Stem],
147 stem_format: StemFormat,
148 character_set: CharacterSet,
149) -> Vec<DesiredStem> {
150 let mut seen: BTreeSet<String> = BTreeSet::new();
151 let mut out = Vec::new();
152 for (index, stem) in stems.iter().enumerate() {
153 let base_key = if !stem.id.is_empty() {
154 stem.id.clone()
155 } else if !stem.label.is_empty() {
156 stem.label.clone()
157 } else {
158 format!("stem{index}")
159 };
160 // Keep the key unique even when ids are blank and labels duplicate.
161 let mut key = base_key.clone();
162 let mut suffix = 1;
163 while !seen.insert(key.clone()) {
164 key = format!("{base_key}-{suffix}");
165 suffix += 1;
166 }
167 // Disambiguate by stable stem id when present, else the key, so two
168 // stems never map to the same file.
169 let disambiguator = if stem.id.is_empty() {
170 key.as_str()
171 } else {
172 stem.id.as_str()
173 };
174 // WAV needs the stem's own id to render; without one, store as MP3.
175 let format = if stem_format == StemFormat::Wav && stem.id.is_empty() {
176 StemFormat::Mp3
177 } else {
178 stem_format
179 };
180 let path = stem_file_path(
181 base,
182 &stem.label,
183 disambiguator,
184 format.ext(),
185 character_set,
186 );
187 out.push(DesiredStem {
188 key,
189 stem_id: stem.id.clone(),
190 path,
191 source_url: stem.url.clone(),
192 format,
193 hash: art_url_hash(&stem.url),
194 });
195 }
196 out
197}
198
199/// The per-clip sidecars desired alongside `base`, the extensionless audio path.
200///
201/// A static `CoverJpg` is emitted only when the clip has non-empty selected art;
202/// an empty art URL emits none, and reconcile reads a missing cover as
203/// UNKNOWN => KEEP, so a transient empty URL never removes an existing cover. The
204/// animated cover is not a sidecar: under `toggles.animated_covers` it is
205/// embedded as the audio file's front cover.
206///
207/// Text sidecars carry their body inline plus a `content_hash`, so an edit
208/// rewrites them even when `meta_hash` is unchanged. `DetailsTxt` is emitted
209/// whenever `toggles.details` is set; `LyricsTxt` only when the clip has lyrics,
210/// so no empty file is written. `Lrc` is emitted for every clip under
211/// `toggles.lrc` (alignment is knowable only from the endpoint, not the feed)
212/// with no inline body, resolved just before execution; a clip with neither
213/// alignment nor lyrics writes nothing, its emptiness cached so it is not
214/// re-fetched.
215fn clip_artifacts(
216 clip: &Clip,
217 base: &str,
218 lineage: &LineageContext,
219 toggles: ArtifactToggles,
220) -> Vec<DesiredArtifact> {
221 let mut artifacts = Vec::new();
222 if let Some(url) = clip.selected_image_url().filter(|u| !u.is_empty()) {
223 artifacts.push(DesiredArtifact {
224 kind: ArtifactKind::CoverJpg,
225 path: format!("{base}.jpg"),
226 source_url: url.to_owned(),
227 hash: art_hash(clip),
228 content: None,
229 });
230 }
231 if toggles.details {
232 let text = render_clip_details(clip, lineage);
233 artifacts.push(DesiredArtifact {
234 kind: ArtifactKind::DetailsTxt,
235 path: format!("{base}.details.txt"),
236 source_url: String::new(),
237 hash: content_hash(&text),
238 content: Some(text),
239 });
240 }
241 if toggles.lyrics
242 && let Some(text) = render_clip_lyrics(clip)
243 {
244 artifacts.push(DesiredArtifact {
245 kind: ArtifactKind::LyricsTxt,
246 path: format!("{base}.lyrics.txt"),
247 source_url: String::new(),
248 hash: content_hash(&text),
249 content: Some(text),
250 });
251 }
252 if toggles.lrc {
253 artifacts.push(DesiredArtifact {
254 kind: ArtifactKind::Lrc,
255 path: format!("{base}.lrc"),
256 source_url: String::new(),
257 hash: synced_lrc_source_hash(&clip.id),
258 content: None,
259 });
260 }
261 if toggles.video && !clip.video_url.is_empty() {
262 artifacts.push(DesiredArtifact {
263 kind: ArtifactKind::VideoMp4,
264 path: format!("{base}.mp4"),
265 source_url: clip.video_url.clone(),
266 hash: art_url_hash(&clip.video_url),
267 content: None,
268 });
269 }
270 artifacts
271}
272
273/// Build the desired `.m3u8` playlists for this run from the fetched playlists.
274///
275/// Each input is rendered in Suno order: every member clip id is looked up in
276/// this run's `desired` audio set for its relative path, title, and duration. A
277/// member absent from that set is emitted as a `# (not in library)` comment (an
278/// empty relative path) rather than a dangling path (HARDENING L1). The content
279/// hash covers the whole rendered body so any name, order, path, title, or
280/// duration change rewrites the file (HARDENING B1), named
281/// `<sanitised name>.m3u8` at the library root.
282///
283/// Pure: the caller does the best-effort fetching, excludes any playlist whose
284/// member fetch failed, and appends the synthetic liked feed.
285pub fn build_playlist_desired(
286 inputs: &[PlaylistInput<'_>],
287 desired: &[Desired],
288) -> Vec<PlaylistDesired> {
289 let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
290 inputs
291 .iter()
292 .map(|input| {
293 let entries: Vec<M3u8Entry<'_>> = input
294 .members
295 .iter()
296 .map(|member| match by_id.get(member.id.as_str()) {
297 Some(d) => M3u8Entry {
298 title: d.clip.title.as_str(),
299 duration_secs: d.clip.duration,
300 relative_path: d.path.as_str(),
301 },
302 None => M3u8Entry {
303 title: member.title.as_str(),
304 duration_secs: member.duration,
305 relative_path: "",
306 },
307 })
308 .collect();
309 let content = render_m3u8(input.name, &entries);
310 let hash = content_hash(&content);
311 let path = format!("{}.m3u8", sanitise_name(input.name));
312 PlaylistDesired {
313 id: input.id.to_owned(),
314 name: input.name.to_owned(),
315 path,
316 content,
317 hash,
318 }
319 })
320 .collect()
321}
322
323/// Render a relative path as a forward-slash string, dropping any non-normal
324/// component so the stored path is portable and never escapes the root.
325///
326/// The single source of truth for turning a rendered path into a stored/compared
327/// one: `/`-separated on every OS, so manifest paths, `parent_dir`, and the
328/// deletion-safety checks behave identically across platforms.
329pub(crate) fn rel_to_string(path: &Path) -> String {
330 path.components()
331 .filter_map(|component| match component {
332 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
333 _ => None,
334 })
335 .collect::<Vec<_>>()
336 .join("/")
337}
338
339#[cfg(test)]
340mod tests;