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