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