Skip to main content

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::client::Stem;
7use crate::config::{AudioFormat, StemFormat};
8use crate::extras::{M3u8Entry, render_clip_details, render_clip_lyrics, render_m3u8};
9use crate::hash::{art_hash, art_url_hash, content_hash, meta_hash, synced_lrc_source_hash};
10use crate::lineage::LineageContext;
11use crate::model::Clip;
12use crate::naming::{
13    CharacterSet, NamingConfig, NamingRequest, render_clip_names, sanitise_name, stem_file_path,
14};
15use crate::reconcile::{
16    ArtifactKind, Desired, DesiredArtifact, DesiredStem, PlaylistDesired, SourceMode,
17};
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.
26///
27/// Each mirrors one resolved setting: `animated_covers` gates the `cover.webp`,
28/// `details` the `.details.txt` dump, `lyrics` the `.lyrics.txt` file, `lrc`
29/// the synced `.lrc` sidecar (Suno's word/line-level timed lyrics, which also
30/// drives the MP3 `SYLT` frame and the plain lyric tag), and `video` the
31/// standalone `.mp4` music video. All default off, matching the compiled config
32/// defaults.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct ArtifactToggles {
35    pub animated_covers: bool,
36    pub details: bool,
37    pub lyrics: bool,
38    pub lrc: bool,
39    pub video: bool,
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 in one
53/// pass, then the target format's extension is appended. Each clip's `modes` is
54/// stamped from `modes_by_id`: the list of every selected area (mirror and copy
55/// alike) that currently holds that clip. A clip held by a `Mirror` and a `Copy`
56/// area at once therefore carries both, so copy-wins protection (SYNC-8) holds.
57///
58/// Every clip in `clips` must have an entry in `modes_by_id` (the caller builds
59/// the map from the same union), so `modes` is never empty; an empty `modes`
60/// would silently drop that clip's copy protection, so it trips a `debug_assert`
61/// (D6). In a release build a clip missing from the map defaults to an empty
62/// list, which reconcile then treats as unprotected, so callers must never omit
63/// a clip from the map.
64///
65/// `contexts` carries the resolved [`LineageContext`] for each clip (keyed by
66/// clip id); it drives the album component, the embedded lineage tags, and the
67/// change hash, so the same resolved values flow all the way to the executor. A
68/// clip missing from `contexts` falls back to a self-rooted context.
69///
70/// `colliding_albums` is the store's authoritative set of root titles shared by
71/// more than one distinct root; a clip whose album is in that set is folded into
72/// a `[{root_id8}]`-suffixed folder so two distinct roots never share one,
73/// regardless of which clips this batch happens to hold.
74///
75/// `toggles` carries the resolved per-song sidecar switches (animated cover,
76/// details text, lyrics text); each gates the matching sidecar in
77/// [`clip_artifacts`].
78pub fn build_desired(
79    clips: &[&Clip],
80    format: AudioFormat,
81    modes_by_id: &HashMap<String, Vec<SourceMode>>,
82    contexts: &HashMap<String, LineageContext>,
83    colliding_albums: &BTreeSet<String>,
84    toggles: ArtifactToggles,
85    naming: &NamingConfig,
86) -> Vec<Desired> {
87    let lineages: Vec<LineageContext> = clips
88        .iter()
89        .map(|clip| {
90            contexts
91                .get(&clip.id)
92                .cloned()
93                .unwrap_or_else(|| LineageContext::own_root(clip))
94        })
95        .collect();
96    // The requests borrow `lineages`; scope them so the borrow ends before the
97    // lineages are moved into the desired entries below.
98    let names = {
99        let requests: Vec<NamingRequest<'_>> = clips
100            .iter()
101            .zip(&lineages)
102            .map(|(clip, lineage)| NamingRequest { clip, lineage })
103            .collect();
104        render_clip_names(&requests, naming, colliding_albums)
105    };
106
107    clips
108        .iter()
109        .zip(names)
110        .zip(lineages)
111        .map(|((clip, name), lineage)| {
112            // The extensionless audio path; the sidecars swap the extension.
113            let base = rel_to_string(&name.relative_path);
114            let path = format!("{base}.{}", format.ext());
115            let meta_hash = meta_hash(clip, &lineage);
116            let modes = modes_by_id.get(&clip.id).cloned().unwrap_or_default();
117            // D6: an empty modes vec would silently lose SYNC-8 copy protection
118            // for this clip, so the caller must always list at least one area.
119            debug_assert!(
120                !modes.is_empty(),
121                "clip {} has no modes in the union map",
122                clip.id
123            );
124            // Bind the artifacts before the struct literal so `&lineage` is
125            // borrowed (for the details render) before it is moved in below.
126            let artifacts = clip_artifacts(clip, &base, &lineage, toggles);
127            Desired {
128                clip: (*clip).clone(),
129                lineage,
130                path,
131                format,
132                meta_hash,
133                art_hash: art_hash(clip),
134                modes,
135                trashed: clip.is_trashed,
136                private: false,
137                artifacts,
138                // Stems are threaded in after this pure pass (they need a network
139                // listing); `None` means "no authoritative stem info", so a run
140                // without `download_stems` leaves any local stems untouched.
141                stems: None,
142            }
143        })
144        .collect()
145}
146
147/// Build the authoritative desired stem set for one clip from its listed stems.
148///
149/// `base` is the clip's extensionless audio path, so each stem file sits in the
150/// `{base}.stems/` sub-folder beside the song. Keys are the stable stem id
151/// (falling back to the label, then a positional key), de-duplicated so blank or
152/// duplicate labels never collide. The file name and its `[stem id8]`
153/// disambiguator come from [`stem_file_path`], honouring the run's character
154/// set, and its extension is the resolved [`StemFormat`] — stems are stored RAW
155/// (WAV by default, or MP3), never transcoded to FLAC. The rewrite hash tracks
156/// the stem's public MP3 URL (a changed URL, or a format switch that moves the
157/// path, re-downloads), mirroring the video sidecar.
158///
159/// A `Wav` stem needs the stem's own id to render its lossless WAV, so a
160/// (degenerate) stem with no id falls back to `Mp3` for that stem alone.
161///
162/// Only ever called with an AUTHORITATIVE listing (see run.rs), so the returned
163/// set is safe to drive stem removals against.
164pub fn clip_stems(
165    base: &str,
166    stems: &[Stem],
167    stem_format: StemFormat,
168    character_set: CharacterSet,
169) -> Vec<DesiredStem> {
170    let mut seen: BTreeSet<String> = BTreeSet::new();
171    let mut out = Vec::new();
172    for (index, stem) in stems.iter().enumerate() {
173        let base_key = if !stem.id.is_empty() {
174            stem.id.clone()
175        } else if !stem.label.is_empty() {
176            stem.label.clone()
177        } else {
178            format!("stem{index}")
179        };
180        // Ensure the manifest key is unique within the clip even when ids are
181        // blank and labels duplicate.
182        let mut key = base_key.clone();
183        let mut suffix = 1;
184        while !seen.insert(key.clone()) {
185            key = format!("{base_key}-{suffix}");
186            suffix += 1;
187        }
188        // The filename disambiguator is the stable stem id when present, else the
189        // resolved key, so two stems can never map to the same file.
190        let disambiguator = if stem.id.is_empty() {
191            key.as_str()
192        } else {
193            stem.id.as_str()
194        };
195        // WAV needs the stem's own id to render; without one, store this stem as
196        // MP3 so the extension always matches what actually gets written.
197        let format = if stem_format == StemFormat::Wav && stem.id.is_empty() {
198            StemFormat::Mp3
199        } else {
200            stem_format
201        };
202        let path = stem_file_path(
203            base,
204            &stem.label,
205            disambiguator,
206            format.ext(),
207            character_set,
208        );
209        out.push(DesiredStem {
210            key,
211            stem_id: stem.id.clone(),
212            path,
213            source_url: stem.url.clone(),
214            format,
215            hash: art_url_hash(&stem.url),
216        });
217    }
218    out
219}
220
221/// The per-clip sidecars desired alongside `base`, the extensionless audio path
222/// (so each sidecar sits next to the audio file).
223///
224/// A static `CoverJpg` is emitted whenever the clip has non-empty selected art;
225/// an animated `CoverWebp` only when `toggles.animated_covers` is set and the
226/// clip carries a video preview. An empty art URL emits NO `CoverJpg`: reconcile
227/// reads a desired that simply lacks a cover as UNKNOWN => KEEP, never a delete,
228/// so a transient empty URL cannot strand or remove an existing cover. The
229/// `CoverJpg` hash tracks the art URL (`art_hash`); the `CoverWebp` hash tracks
230/// the video URL, so a changed source re-transcodes.
231///
232/// The generated text sidecars carry their body inline (`content`) and a
233/// per-sidecar `content_hash`, so a change to what the file holds (a retitle for
234/// details, or edited lyrics) rewrites it even when `meta_hash` is unchanged.
235/// `DetailsTxt` is always emitted when `toggles.details` is set (the render is
236/// total); `LyricsTxt` only when `toggles.lyrics` is set and the clip has
237/// non-empty lyrics (the render is partial), so no empty lyrics file is written.
238/// The synced `Lrc` is emitted under `toggles.lrc` for every clip (alignment
239/// availability is knowable only from the endpoint, not the feed), carrying a
240/// source-proxy hash and no inline body; its timed body is resolved from the
241/// fetched alignment just before execution, and a clip with neither alignment
242/// nor lyrics writes no file (its emptiness cached so it is not re-fetched).
243fn clip_artifacts(
244    clip: &Clip,
245    base: &str,
246    lineage: &LineageContext,
247    toggles: ArtifactToggles,
248) -> Vec<DesiredArtifact> {
249    let mut artifacts = Vec::new();
250    if let Some(url) = clip.selected_image_url().filter(|u| !u.is_empty()) {
251        artifacts.push(DesiredArtifact {
252            kind: ArtifactKind::CoverJpg,
253            path: format!("{base}.jpg"),
254            source_url: url.to_owned(),
255            hash: art_hash(clip),
256            content: None,
257        });
258    }
259    if toggles.animated_covers && !clip.video_cover_url.is_empty() {
260        artifacts.push(DesiredArtifact {
261            kind: ArtifactKind::CoverWebp,
262            path: format!("{base}.webp"),
263            source_url: clip.video_cover_url.clone(),
264            hash: art_url_hash(&clip.video_cover_url),
265            content: None,
266        });
267    }
268    if toggles.details {
269        let text = render_clip_details(clip, lineage);
270        artifacts.push(DesiredArtifact {
271            kind: ArtifactKind::DetailsTxt,
272            path: format!("{base}.details.txt"),
273            source_url: String::new(),
274            hash: content_hash(&text),
275            content: Some(text),
276        });
277    }
278    if toggles.lyrics
279        && let Some(text) = render_clip_lyrics(clip)
280    {
281        artifacts.push(DesiredArtifact {
282            kind: ArtifactKind::LyricsTxt,
283            path: format!("{base}.lyrics.txt"),
284            source_url: String::new(),
285            hash: content_hash(&text),
286            content: Some(text),
287        });
288    }
289    if toggles.lrc {
290        // Emitted for every clip: alignment availability is knowable only from
291        // the endpoint, not the feed (a clip can carry neither `lyrics` nor a
292        // `prompt` yet still have full word/line alignment), so the fetch itself
293        // decides. The artifact carries no inline body and a source-proxy hash
294        // keyed on the (immutable) clip id plus the render version, so reconcile
295        // skips an unchanged clip with no fetch while a version bump rewrites
296        // every sidecar. The body is resolved just before execution (the untimed
297        // lyrics when Suno has no alignment); a clip with neither alignment nor
298        // lyrics resolves to nothing and writes no `.lrc`, its emptiness cached
299        // on the manifest so it is not re-fetched every run.
300        artifacts.push(DesiredArtifact {
301            kind: ArtifactKind::Lrc,
302            path: format!("{base}.lrc"),
303            source_url: String::new(),
304            hash: synced_lrc_source_hash(&clip.id),
305            content: None,
306        });
307    }
308    if toggles.video && !clip.video_url.is_empty() {
309        artifacts.push(DesiredArtifact {
310            kind: ArtifactKind::VideoMp4,
311            path: format!("{base}.mp4"),
312            source_url: clip.video_url.clone(),
313            hash: art_url_hash(&clip.video_url),
314            content: None,
315        });
316    }
317    artifacts
318}
319
320/// Build the desired `.m3u8` playlists for this run from the fetched playlists.
321///
322/// Each input is rendered, in Suno order, into an extended-M3U8 body: every
323/// member clip id is looked up in this run's `desired` audio set and mapped to
324/// its rendered relative path, title, and duration. A member **absent from the
325/// desired set** is emitted as an L1 `# (not in library)` comment (an empty
326/// relative path in the [`M3u8Entry`]), using the member's own title, rather
327/// than a dangling path (HARDENING L1). The content hash is taken over the full
328/// rendered body so a name, order, path, title, or duration change all trigger a
329/// rewrite (HARDENING B1), and the file path is `<sanitised name>.m3u8` at the
330/// library root.
331///
332/// This is pure; the caller (run) does the best-effort fetching, excludes any
333/// playlist whose member fetch failed, and appends the synthetic liked feed as a
334/// final input with id [`LIKED_PLAYLIST_ID`].
335pub fn build_playlist_desired(
336    inputs: &[PlaylistInput<'_>],
337    desired: &[Desired],
338) -> Vec<PlaylistDesired> {
339    let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
340    inputs
341        .iter()
342        .map(|input| {
343            let entries: Vec<M3u8Entry<'_>> = input
344                .members
345                .iter()
346                .map(|member| match by_id.get(member.id.as_str()) {
347                    Some(d) => M3u8Entry {
348                        title: d.clip.title.as_str(),
349                        duration_secs: d.clip.duration,
350                        relative_path: d.path.as_str(),
351                    },
352                    None => M3u8Entry {
353                        title: member.title.as_str(),
354                        duration_secs: member.duration,
355                        relative_path: "",
356                    },
357                })
358                .collect();
359            let content = render_m3u8(input.name, &entries);
360            let hash = content_hash(&content);
361            let path = format!("{}.m3u8", sanitise_name(input.name));
362            PlaylistDesired {
363                id: input.id.to_owned(),
364                name: input.name.to_owned(),
365                path,
366                content,
367                hash,
368            }
369        })
370        .collect()
371}
372
373/// Render a relative path as a forward-slash string, dropping any non-normal
374/// component so the stored path is portable and never escapes the root.
375fn rel_to_string(path: &Path) -> String {
376    path.components()
377        .filter_map(|component| match component {
378            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
379            _ => None,
380        })
381        .collect::<Vec<_>>()
382        .join("/")
383}
384
385#[cfg(test)]
386mod tests {
387    use std::collections::BTreeSet;
388    use std::collections::HashMap;
389
390    use super::*;
391    use crate::config::AudioFormat;
392    use crate::hash::{art_hash, art_url_hash, content_hash, synced_lrc_source_hash};
393    use crate::lineage::LineageContext;
394    use crate::naming::NamingConfig;
395    use crate::reconcile::{ArtifactKind, SourceMode};
396
397    fn clip(id: &str, title: &str, handle: &str) -> Clip {
398        Clip {
399            id: id.to_owned(),
400            title: title.to_owned(),
401            handle: handle.to_owned(),
402            display_name: handle.to_owned(),
403            ..Default::default()
404        }
405    }
406
407    fn no_contexts() -> HashMap<String, LineageContext> {
408        HashMap::new()
409    }
410
411    fn no_collisions() -> BTreeSet<String> {
412        BTreeSet::new()
413    }
414
415    fn modes_for(clips: &[&Clip], mode: SourceMode) -> HashMap<String, Vec<SourceMode>> {
416        clips.iter().map(|c| (c.id.clone(), vec![mode])).collect()
417    }
418
419    fn art_clip(id: &str) -> Clip {
420        Clip {
421            image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
422            ..clip(id, "Song", "alice")
423        }
424    }
425
426    fn path_of<'a>(desired: &'a [Desired], id: &str) -> &'a str {
427        desired
428            .iter()
429            .find(|d| d.clip.id == id)
430            .map(|d| d.path.as_str())
431            .expect("clip in desired set")
432    }
433
434    #[test]
435    fn build_desired_appends_extension_and_mode() {
436        let a = clip("id-a", "Song A", "alice");
437        let clips = [&a];
438        let desired = build_desired(
439            &clips,
440            AudioFormat::Flac,
441            &modes_for(&clips, SourceMode::Mirror),
442            &no_contexts(),
443            &no_collisions(),
444            ArtifactToggles::default(),
445            &NamingConfig::default(),
446        );
447        assert_eq!(desired.len(), 1);
448        assert!(
449            desired[0].path.ends_with(".flac"),
450            "path: {}",
451            desired[0].path
452        );
453        assert_eq!(desired[0].format, AudioFormat::Flac);
454        assert_eq!(desired[0].modes, vec![SourceMode::Mirror]);
455        assert!(!desired[0].trashed);
456        assert!(!desired[0].private);
457        let lineage = LineageContext::own_root(&a);
458        assert_eq!(desired[0].meta_hash, crate::hash::meta_hash(&a, &lineage));
459        assert_eq!(desired[0].art_hash, art_hash(&a));
460        assert_eq!(desired[0].lineage, lineage);
461    }
462
463    #[test]
464    fn build_desired_carries_the_trashed_flag_from_the_clip() {
465        let mut gone = clip("id-gone", "Removed", "alice");
466        gone.is_trashed = true;
467        let live = clip("id-live", "Kept", "alice");
468        let clips = [&gone, &live];
469        let desired = build_desired(
470            &clips,
471            AudioFormat::Flac,
472            &modes_for(&clips, SourceMode::Mirror),
473            &no_contexts(),
474            &no_collisions(),
475            ArtifactToggles::default(),
476            &NamingConfig::default(),
477        );
478        assert!(desired[0].trashed, "a trashed clip is marked trashed");
479        assert!(!desired[1].trashed, "a live clip is not");
480    }
481
482    #[test]
483    fn build_desired_uses_supplied_lineage_context() {
484        use crate::lineage::ResolveStatus;
485
486        let a = clip("child-1", "Remix", "alice");
487        let clips = [&a];
488        let lineage = LineageContext {
489            root_id: "root-1".to_owned(),
490            root_title: "Original".to_owned(),
491            root_date: String::new(),
492            parent_id: "root-1".to_owned(),
493            edge_type: None,
494            status: ResolveStatus::Resolved,
495        };
496        let contexts: HashMap<String, LineageContext> =
497            [(a.id.clone(), lineage.clone())].into_iter().collect();
498        let desired = build_desired(
499            &clips,
500            AudioFormat::Flac,
501            &modes_for(&clips, SourceMode::Mirror),
502            &contexts,
503            &no_collisions(),
504            ArtifactToggles::default(),
505            &NamingConfig::default(),
506        );
507        assert!(
508            desired[0].path.contains("/Original/"),
509            "path: {}",
510            desired[0].path
511        );
512        assert_eq!(desired[0].lineage, lineage);
513        assert_eq!(desired[0].meta_hash, crate::hash::meta_hash(&a, &lineage));
514    }
515
516    #[test]
517    fn lineage_is_stable_when_a_later_resolution_fails() {
518        use crate::graph::LineageStore;
519        use crate::lineage::{Resolution, ResolveStatus, RootInfo};
520
521        let root = Clip {
522            id: "root-break".into(),
523            title: "Break Through".into(),
524            clip_type: "gen".into(),
525            handle: "alice".into(),
526            display_name: "alice".into(),
527            ..Default::default()
528        };
529        let child = Clip {
530            id: "child-remix".into(),
531            title: "Remix".into(),
532            clip_type: "gen".into(),
533            task: "cover".into(),
534            cover_clip_id: "root-break".into(),
535            edited_clip_id: "root-break".into(),
536            handle: "alice".into(),
537            display_name: "alice".into(),
538            ..Default::default()
539        };
540        let clips = [&root, &child];
541
542        let contexts_of = |store: &LineageStore| -> HashMap<String, LineageContext> {
543            clips
544                .iter()
545                .map(|c| (c.id.clone(), store.context_for(c)))
546                .collect()
547        };
548
549        let mut roots = HashMap::new();
550        for id in ["root-break", "child-remix"] {
551            roots.insert(
552                id.to_owned(),
553                RootInfo {
554                    root_id: "root-break".into(),
555                    root_title: "Break Through".into(),
556                    status: ResolveStatus::Resolved,
557                },
558            );
559        }
560        let resolution = Resolution {
561            roots,
562            gap_filled: Vec::new(),
563            bridges: Vec::new(),
564        };
565        let mut store = LineageStore::new();
566        store.update(&[root.clone(), child.clone()], &resolution, "t1");
567
568        let cycle1 = build_desired(
569            &clips,
570            AudioFormat::Flac,
571            &modes_for(&clips, SourceMode::Mirror),
572            &contexts_of(&store),
573            &store.colliding_root_titles(),
574            ArtifactToggles::default(),
575            &NamingConfig::default(),
576        );
577        let child1 = cycle1.iter().find(|d| d.clip.id == "child-remix").unwrap();
578        assert!(
579            child1.path.contains("/Break Through/"),
580            "the remix should folder under its root album, got {}",
581            child1.path
582        );
583
584        let cycle2 = build_desired(
585            &clips,
586            AudioFormat::Flac,
587            &modes_for(&clips, SourceMode::Mirror),
588            &contexts_of(&store),
589            &store.colliding_root_titles(),
590            ArtifactToggles::default(),
591            &NamingConfig::default(),
592        );
593        for (a, b) in cycle1.iter().zip(&cycle2) {
594            assert_eq!(a.path, b.path, "album path drifted for {}", a.clip.id);
595            assert_eq!(
596                a.meta_hash, b.meta_hash,
597                "meta_hash drifted for {}",
598                a.clip.id
599            );
600        }
601
602        let own = LineageContext::own_root(&child);
603        assert_ne!(
604            crate::hash::meta_hash(&child, &own),
605            child1.meta_hash,
606            "own-root fallback must differ from the store-driven hash"
607        );
608    }
609
610    #[test]
611    fn build_desired_disambiguates_collisions() {
612        let a = clip("id-a", "Same", "alice");
613        let b = clip("id-b", "Same", "alice");
614        let clips = [&a, &b];
615        let desired = build_desired(
616            &clips,
617            AudioFormat::Mp3,
618            &modes_for(&clips, SourceMode::Copy),
619            &no_contexts(),
620            &no_collisions(),
621            ArtifactToggles::default(),
622            &NamingConfig::default(),
623        );
624        assert_ne!(desired[0].path, desired[1].path);
625        assert!(desired.iter().all(|d| d.path.ends_with(".mp3")));
626        assert!(desired.iter().all(|d| d.modes == vec![SourceMode::Copy]));
627    }
628
629    #[test]
630    fn build_desired_uses_forward_slashes() {
631        let a = clip("id-a", "Song A", "alice");
632        let clips = [&a];
633        let desired = build_desired(
634            &clips,
635            AudioFormat::Flac,
636            &modes_for(&clips, SourceMode::Mirror),
637            &no_contexts(),
638            &no_collisions(),
639            ArtifactToggles::default(),
640            &NamingConfig::default(),
641        );
642        assert!(!desired[0].path.contains('\\'));
643        assert!(desired[0].path.contains('/'));
644    }
645
646    #[test]
647    fn build_desired_emits_cover_jpg_next_to_audio() {
648        let a = art_clip("id-a");
649        let clips = [&a];
650        let desired = build_desired(
651            &clips,
652            AudioFormat::Flac,
653            &modes_for(&clips, SourceMode::Mirror),
654            &no_contexts(),
655            &no_collisions(),
656            ArtifactToggles::default(),
657            &NamingConfig::default(),
658        );
659        let base = desired[0].path.strip_suffix(".flac").unwrap();
660        assert_eq!(desired[0].artifacts.len(), 1);
661        let jpg = &desired[0].artifacts[0];
662        assert_eq!(jpg.kind, ArtifactKind::CoverJpg);
663        assert_eq!(jpg.path, format!("{base}.jpg"));
664        assert_eq!(jpg.source_url, a.selected_image_url().unwrap());
665        assert_eq!(jpg.hash, art_hash(&a));
666    }
667
668    #[test]
669    fn build_desired_omits_cover_jpg_when_art_is_empty() {
670        let a = clip("id-a", "Song", "alice");
671        assert!(a.selected_image_url().is_none());
672        let clips = [&a];
673        let desired = build_desired(
674            &clips,
675            AudioFormat::Flac,
676            &modes_for(&clips, SourceMode::Mirror),
677            &no_contexts(),
678            &no_collisions(),
679            ArtifactToggles {
680                animated_covers: true,
681                ..Default::default()
682            },
683            &NamingConfig::default(),
684        );
685        assert!(desired[0].artifacts.is_empty());
686    }
687
688    #[test]
689    fn build_desired_emits_cover_webp_only_when_animated_and_video_present() {
690        let with_video = Clip {
691            video_cover_url: "https://cdn.suno.ai/id-a/video.mp4".to_owned(),
692            ..art_clip("id-a")
693        };
694        let clips = [&with_video];
695
696        let desired = build_desired(
697            &clips,
698            AudioFormat::Flac,
699            &modes_for(&clips, SourceMode::Mirror),
700            &no_contexts(),
701            &no_collisions(),
702            ArtifactToggles::default(),
703            &NamingConfig::default(),
704        );
705        assert_eq!(desired[0].artifacts.len(), 1);
706        assert_eq!(desired[0].artifacts[0].kind, ArtifactKind::CoverJpg);
707
708        let desired = build_desired(
709            &clips,
710            AudioFormat::Flac,
711            &modes_for(&clips, SourceMode::Mirror),
712            &no_contexts(),
713            &no_collisions(),
714            ArtifactToggles {
715                animated_covers: true,
716                ..Default::default()
717            },
718            &NamingConfig::default(),
719        );
720        let base = desired[0].path.strip_suffix(".flac").unwrap();
721        let webp = desired[0]
722            .artifacts
723            .iter()
724            .find(|art| art.kind == ArtifactKind::CoverWebp)
725            .expect("animated cover expected");
726        assert_eq!(webp.path, format!("{base}.webp"));
727        assert_eq!(webp.source_url, with_video.video_cover_url);
728        assert_eq!(webp.hash, art_url_hash(&with_video.video_cover_url));
729
730        let no_video = art_clip("id-b");
731        let clips = [&no_video];
732        let desired = build_desired(
733            &clips,
734            AudioFormat::Flac,
735            &modes_for(&clips, SourceMode::Mirror),
736            &no_contexts(),
737            &no_collisions(),
738            ArtifactToggles {
739                animated_covers: true,
740                ..Default::default()
741            },
742            &NamingConfig::default(),
743        );
744        assert!(
745            desired[0]
746                .artifacts
747                .iter()
748                .all(|art| art.kind != ArtifactKind::CoverWebp)
749        );
750    }
751
752    #[test]
753    fn build_desired_emits_video_mp4_only_when_enabled_and_video_present() {
754        let with_video = Clip {
755            video_url: "https://cdn.suno.ai/id-a/video.mp4".to_owned(),
756            ..art_clip("id-a")
757        };
758        let clips = [&with_video];
759
760        let desired = build_desired(
761            &clips,
762            AudioFormat::Flac,
763            &modes_for(&clips, SourceMode::Mirror),
764            &no_contexts(),
765            &no_collisions(),
766            ArtifactToggles::default(),
767            &NamingConfig::default(),
768        );
769        assert!(
770            desired[0]
771                .artifacts
772                .iter()
773                .all(|art| art.kind != ArtifactKind::VideoMp4)
774        );
775
776        let desired = build_desired(
777            &clips,
778            AudioFormat::Flac,
779            &modes_for(&clips, SourceMode::Mirror),
780            &no_contexts(),
781            &no_collisions(),
782            ArtifactToggles {
783                video: true,
784                ..Default::default()
785            },
786            &NamingConfig::default(),
787        );
788        let base = desired[0].path.strip_suffix(".flac").unwrap();
789        let video = desired[0]
790            .artifacts
791            .iter()
792            .find(|art| art.kind == ArtifactKind::VideoMp4)
793            .expect("video expected");
794        assert_eq!(video.path, format!("{base}.mp4"));
795        assert_eq!(video.source_url, with_video.video_url);
796        assert_eq!(video.hash, art_url_hash(&with_video.video_url));
797        assert!(video.content.is_none());
798
799        let no_video = art_clip("id-b");
800        let clips = [&no_video];
801        let desired = build_desired(
802            &clips,
803            AudioFormat::Flac,
804            &modes_for(&clips, SourceMode::Mirror),
805            &no_contexts(),
806            &no_collisions(),
807            ArtifactToggles {
808                video: true,
809                ..Default::default()
810            },
811            &NamingConfig::default(),
812        );
813        assert!(
814            desired[0]
815                .artifacts
816                .iter()
817                .all(|art| art.kind != ArtifactKind::VideoMp4)
818        );
819    }
820
821    #[test]
822    fn build_desired_emits_details_sidecar_only_when_enabled() {
823        use crate::extras::render_clip_details;
824        use crate::hash::content_hash;
825
826        let a = clip("id-a", "Song", "alice");
827        let clips = [&a];
828
829        let off = build_desired(
830            &clips,
831            AudioFormat::Flac,
832            &modes_for(&clips, SourceMode::Mirror),
833            &no_contexts(),
834            &no_collisions(),
835            ArtifactToggles::default(),
836            &NamingConfig::default(),
837        );
838        assert!(
839            off[0]
840                .artifacts
841                .iter()
842                .all(|art| art.kind != ArtifactKind::DetailsTxt)
843        );
844
845        let on = build_desired(
846            &clips,
847            AudioFormat::Flac,
848            &modes_for(&clips, SourceMode::Mirror),
849            &no_contexts(),
850            &no_collisions(),
851            ArtifactToggles {
852                details: true,
853                ..Default::default()
854            },
855            &NamingConfig::default(),
856        );
857        let base = on[0].path.strip_suffix(".flac").unwrap();
858        let details = on[0]
859            .artifacts
860            .iter()
861            .find(|art| art.kind == ArtifactKind::DetailsTxt)
862            .expect("details sidecar expected");
863        assert_eq!(details.path, format!("{base}.details.txt"));
864        assert_eq!(details.source_url, "");
865        let body = render_clip_details(&a, &LineageContext::own_root(&a));
866        assert_eq!(details.content.as_deref(), Some(body.as_str()));
867        assert_eq!(details.hash, content_hash(&body));
868    }
869
870    #[test]
871    fn build_desired_emits_lyrics_sidecar_only_when_enabled_and_present() {
872        let with_lyrics = Clip {
873            lyrics: "la la la".to_owned(),
874            ..clip("id-a", "Song", "alice")
875        };
876        let clips = [&with_lyrics];
877
878        let off = build_desired(
879            &clips,
880            AudioFormat::Flac,
881            &modes_for(&clips, SourceMode::Mirror),
882            &no_contexts(),
883            &no_collisions(),
884            ArtifactToggles::default(),
885            &NamingConfig::default(),
886        );
887        assert!(
888            off[0]
889                .artifacts
890                .iter()
891                .all(|art| art.kind != ArtifactKind::LyricsTxt)
892        );
893
894        let on = build_desired(
895            &clips,
896            AudioFormat::Flac,
897            &modes_for(&clips, SourceMode::Mirror),
898            &no_contexts(),
899            &no_collisions(),
900            ArtifactToggles {
901                lyrics: true,
902                ..Default::default()
903            },
904            &NamingConfig::default(),
905        );
906        let base = on[0].path.strip_suffix(".flac").unwrap();
907        let lyrics = on[0]
908            .artifacts
909            .iter()
910            .find(|art| art.kind == ArtifactKind::LyricsTxt)
911            .expect("lyrics sidecar expected");
912        assert_eq!(lyrics.path, format!("{base}.lyrics.txt"));
913        assert_eq!(lyrics.source_url, "");
914        assert_eq!(lyrics.content.as_deref(), Some("la la la\n"));
915        assert_eq!(lyrics.hash, content_hash("la la la\n"));
916    }
917
918    #[test]
919    fn build_desired_emits_lrc_sidecar_only_when_enabled() {
920        let with_lyrics = Clip {
921            lyrics: "la la la".to_owned(),
922            ..clip("id-a", "Song", "alice")
923        };
924        let clips = [&with_lyrics];
925
926        let off = build_desired(
927            &clips,
928            AudioFormat::Flac,
929            &modes_for(&clips, SourceMode::Mirror),
930            &no_contexts(),
931            &no_collisions(),
932            ArtifactToggles::default(),
933            &NamingConfig::default(),
934        );
935        assert!(
936            off[0]
937                .artifacts
938                .iter()
939                .all(|art| art.kind != ArtifactKind::Lrc)
940        );
941
942        let on = build_desired(
943            &clips,
944            AudioFormat::Flac,
945            &modes_for(&clips, SourceMode::Mirror),
946            &no_contexts(),
947            &no_collisions(),
948            ArtifactToggles {
949                lrc: true,
950                ..Default::default()
951            },
952            &NamingConfig::default(),
953        );
954        let base = on[0].path.strip_suffix(".flac").unwrap();
955        let lrc = on[0]
956            .artifacts
957            .iter()
958            .find(|art| art.kind == ArtifactKind::Lrc)
959            .expect("lrc sidecar expected");
960        assert_eq!(lrc.path, format!("{base}.lrc"));
961        assert_eq!(lrc.source_url, "");
962        assert_eq!(lrc.content, None);
963        assert_eq!(lrc.hash, synced_lrc_source_hash(&with_lyrics.id));
964    }
965
966    #[test]
967    fn build_desired_emits_lrc_sidecar_from_prompt_when_feed_omits_lyrics() {
968        let prompt_only = Clip {
969            prompt: "the sung words live here".to_owned(),
970            ..clip("id-a", "Song", "alice")
971        };
972        assert!(prompt_only.lyrics.is_empty());
973        let clips = [&prompt_only];
974        let desired = build_desired(
975            &clips,
976            AudioFormat::Flac,
977            &modes_for(&clips, SourceMode::Mirror),
978            &no_contexts(),
979            &no_collisions(),
980            ArtifactToggles {
981                lrc: true,
982                ..Default::default()
983            },
984            &NamingConfig::default(),
985        );
986        let lrc = desired[0]
987            .artifacts
988            .iter()
989            .find(|art| art.kind == ArtifactKind::Lrc)
990            .expect("lrc sidecar expected");
991        assert_eq!(lrc.content, None);
992        assert_eq!(lrc.hash, synced_lrc_source_hash(&prompt_only.id));
993    }
994
995    #[test]
996    fn build_desired_emits_lrc_sidecar_even_when_feed_has_no_lyrics_or_prompt() {
997        let bare = clip("id-a", "Song", "alice");
998        assert!(bare.lyrics.is_empty() && bare.prompt.is_empty());
999        let clips = [&bare];
1000        let desired = build_desired(
1001            &clips,
1002            AudioFormat::Flac,
1003            &modes_for(&clips, SourceMode::Mirror),
1004            &no_contexts(),
1005            &no_collisions(),
1006            ArtifactToggles {
1007                lrc: true,
1008                ..Default::default()
1009            },
1010            &NamingConfig::default(),
1011        );
1012        let lrc = desired[0]
1013            .artifacts
1014            .iter()
1015            .find(|art| art.kind == ArtifactKind::Lrc)
1016            .expect("lrc sidecar expected even with no feed lyrics/prompt");
1017        assert_eq!(lrc.content, None);
1018        assert_eq!(lrc.hash, synced_lrc_source_hash(&bare.id));
1019    }
1020
1021    #[test]
1022    fn build_desired_omits_lyrics_sidecar_when_clip_has_no_lyrics() {
1023        let no_lyrics = clip("id-a", "Song", "alice");
1024        assert!(no_lyrics.lyrics.is_empty());
1025        let clips = [&no_lyrics];
1026        let desired = build_desired(
1027            &clips,
1028            AudioFormat::Flac,
1029            &modes_for(&clips, SourceMode::Mirror),
1030            &no_contexts(),
1031            &no_collisions(),
1032            ArtifactToggles {
1033                lyrics: true,
1034                ..Default::default()
1035            },
1036            &NamingConfig::default(),
1037        );
1038        assert!(
1039            desired[0]
1040                .artifacts
1041                .iter()
1042                .all(|art| art.kind != ArtifactKind::LyricsTxt)
1043        );
1044    }
1045
1046    #[test]
1047    fn build_desired_text_sidecars_are_independent() {
1048        let full = Clip {
1049            lyrics: "words".to_owned(),
1050            ..art_clip("id-a")
1051        };
1052        let clips = [&full];
1053        let desired = build_desired(
1054            &clips,
1055            AudioFormat::Flac,
1056            &modes_for(&clips, SourceMode::Mirror),
1057            &no_contexts(),
1058            &no_collisions(),
1059            ArtifactToggles {
1060                details: true,
1061                lyrics: true,
1062                ..Default::default()
1063            },
1064            &NamingConfig::default(),
1065        );
1066        let base = desired[0].path.strip_suffix(".flac").unwrap();
1067        let kinds: BTreeSet<ArtifactKind> = desired[0].artifacts.iter().map(|a| a.kind).collect();
1068        assert!(kinds.contains(&ArtifactKind::CoverJpg));
1069        assert!(kinds.contains(&ArtifactKind::DetailsTxt));
1070        assert!(kinds.contains(&ArtifactKind::LyricsTxt));
1071        let path_of_kind = |k: ArtifactKind| {
1072            desired[0]
1073                .artifacts
1074                .iter()
1075                .find(|a| a.kind == k)
1076                .unwrap()
1077                .path
1078                .clone()
1079        };
1080        assert_eq!(
1081            path_of_kind(ArtifactKind::DetailsTxt),
1082            format!("{base}.details.txt")
1083        );
1084        assert_eq!(
1085            path_of_kind(ArtifactKind::LyricsTxt),
1086            format!("{base}.lyrics.txt")
1087        );
1088    }
1089
1090    #[test]
1091    fn build_desired_one_pass_disambiguates_and_stamps_modes() {
1092        let a = clip("lib-1", "Song", "alice");
1093        let b = clip("pl-1", "Song", "alice");
1094        let clips = [&a, &b];
1095        let mut modes = HashMap::new();
1096        modes.insert("lib-1".to_owned(), vec![SourceMode::Copy]);
1097        modes.insert(
1098            "pl-1".to_owned(),
1099            vec![SourceMode::Mirror, SourceMode::Copy],
1100        );
1101        let desired = build_desired(
1102            &clips,
1103            AudioFormat::Flac,
1104            &modes,
1105            &no_contexts(),
1106            &no_collisions(),
1107            ArtifactToggles::default(),
1108            &NamingConfig::default(),
1109        );
1110        assert_eq!(desired.len(), 2);
1111        assert_ne!(desired[0].path, desired[1].path);
1112        assert_eq!(desired[1].modes, vec![SourceMode::Mirror, SourceMode::Copy]);
1113    }
1114
1115    #[test]
1116    fn build_desired_respects_custom_naming_config() {
1117        use crate::naming::CharacterSet;
1118
1119        let a = clip("abcdefgh-1234", "Song A", "alice");
1120        let clips = [&a];
1121        let custom = NamingConfig {
1122            template: "{title}/{id8}".to_owned(),
1123            character_set: CharacterSet::Ascii,
1124            ..NamingConfig::default()
1125        };
1126        let desired = build_desired(
1127            &clips,
1128            AudioFormat::Flac,
1129            &HashMap::from([("abcdefgh-1234".to_owned(), vec![SourceMode::Mirror])]),
1130            &no_contexts(),
1131            &no_collisions(),
1132            ArtifactToggles::default(),
1133            &custom,
1134        );
1135        assert!(
1136            desired[0].path.starts_with("Song A/"),
1137            "path: {}",
1138            desired[0].path
1139        );
1140        assert!(desired[0].path.contains(&a.id[..8]));
1141    }
1142
1143    #[test]
1144    fn build_playlist_desired_orders_members_and_marks_absent() {
1145        let a = clip("id-a", "Song A", "alice");
1146        let b = clip("id-b", "Song B", "alice");
1147        let desired = build_desired(
1148            &[&a, &b],
1149            AudioFormat::Flac,
1150            &modes_for(&[&a, &b], SourceMode::Mirror),
1151            &no_contexts(),
1152            &no_collisions(),
1153            ArtifactToggles::default(),
1154            &NamingConfig::default(),
1155        );
1156        let missing = clip("id-x", "Missing Song", "bob");
1157        let members = vec![b.clone(), missing.clone(), a.clone()];
1158        let inputs = vec![PlaylistInput {
1159            id: "pl1",
1160            name: "Road/Trip",
1161            members: &members,
1162        }];
1163
1164        let out = build_playlist_desired(&inputs, &desired);
1165        assert_eq!(out.len(), 1);
1166        let pl = &out[0];
1167        assert_eq!(pl.id, "pl1");
1168        assert_eq!(pl.path, "Road Trip.m3u8");
1169        assert!(pl.content.starts_with("#EXTM3U\n#PLAYLIST:Road/Trip\n"));
1170
1171        let pos_b = pl.content.find(path_of(&desired, "id-b")).unwrap();
1172        let pos_missing = pl.content.find("# (not in library) Missing Song").unwrap();
1173        let pos_a = pl.content.find(path_of(&desired, "id-a")).unwrap();
1174        assert!(pos_b < pos_missing && pos_missing < pos_a);
1175        assert!(!pl.content.contains("Missing Song\nbob/"));
1176        assert_eq!(pl.hash, content_hash(&pl.content));
1177    }
1178
1179    #[test]
1180    fn build_playlist_desired_builds_liked_and_multiple_in_order() {
1181        let a = clip("id-a", "Song A", "alice");
1182        let desired = build_desired(
1183            &[&a],
1184            AudioFormat::Flac,
1185            &modes_for(&[&a], SourceMode::Mirror),
1186            &no_contexts(),
1187            &no_collisions(),
1188            ArtifactToggles::default(),
1189            &NamingConfig::default(),
1190        );
1191        let members = vec![a.clone()];
1192        let inputs = vec![
1193            PlaylistInput {
1194                id: "pl1",
1195                name: "First",
1196                members: &members,
1197            },
1198            PlaylistInput {
1199                id: LIKED_PLAYLIST_ID,
1200                name: "Liked Songs",
1201                members: &members,
1202            },
1203        ];
1204
1205        let out = build_playlist_desired(&inputs, &desired);
1206        assert_eq!(out.len(), 2);
1207        assert_eq!(out[0].id, "pl1");
1208        assert_eq!(out[1].id, LIKED_PLAYLIST_ID);
1209        assert_eq!(out[1].path, "Liked Songs.m3u8");
1210        assert!(out[0].content.contains(path_of(&desired, "id-a")));
1211        assert!(out[1].content.contains(path_of(&desired, "id-a")));
1212    }
1213
1214    #[test]
1215    fn build_playlist_desired_is_empty_for_no_inputs() {
1216        assert!(build_playlist_desired(&[], &[]).is_empty());
1217    }
1218}