Skip to main content

suno_core/
extras.rs

1//! Pure "media extras" generators: M3U8 playlists, per-song text sidecars, and
2//! the library index.
3//!
4//! Every function here is pure. It takes clip data plus relative paths and
5//! returns the text the CLI writes to disk later, with no IO, no clock, and no
6//! network, so the logic stays deterministic and is unit-tested in isolation.
7
8use std::collections::HashMap;
9use std::fmt::Write as _;
10
11use serde::Serialize;
12
13use crate::config::AudioFormat;
14use crate::consts::SUNO_SONG_BASE_URL;
15use crate::graph::LineageStore;
16use crate::lineage::LineageContext;
17use crate::manifest::Manifest;
18use crate::model::Clip;
19use crate::tag::TrackMetadata;
20
21/// The schema version of the library index document.
22///
23/// Bump this only when the index shape changes. The field set is additive:
24/// fields may be added, never renamed or repurposed.
25pub const INDEX_SCHEMA_VERSION: u32 = 1;
26
27/// One ordered entry in an extended-M3U8 playlist.
28///
29/// Order is significant: the liked and playlist ordering is preserved exactly
30/// as given.
31///
32/// An **empty `relative_path` marks a member absent from the local library**
33/// (Liked from another creator, or filtered out by `--limit`/`--since`). Such an
34/// entry renders as a `# (not in library) <title>` comment line rather than an
35/// `#EXTINF` + path pair, so the playlist never carries a dangling path
36/// (HARDENING L1). A present member always has a non-empty relative path.
37#[derive(Debug, Clone, Copy)]
38pub struct M3u8Entry<'a> {
39    pub title: &'a str,
40    pub duration_secs: f64,
41    pub relative_path: &'a str,
42}
43
44/// Render an extended-M3U8 playlist named `name` from `entries`, preserving
45/// their order.
46///
47/// The output opens with the `#EXTM3U` header and a `#PLAYLIST:<name>` line,
48/// then per entry emits either an `#EXTINF:<seconds>,<title>` line followed by
49/// the relative path line (a member present in the library), or a
50/// `# (not in library) <title>` comment line (an [`M3u8Entry`] with an empty
51/// relative path — HARDENING L1). Seconds are rounded to the nearest whole
52/// number. Carriage returns and line feeds in the name, title, and path are
53/// folded to spaces so a single field can never break the line structure.
54pub fn render_m3u8(name: &str, entries: &[M3u8Entry<'_>]) -> String {
55    let mut out = String::from("#EXTM3U\n");
56    let _ = writeln!(out, "#PLAYLIST:{}", to_single_line(name));
57    for entry in entries {
58        let title = to_single_line(entry.title);
59        if entry.relative_path.is_empty() {
60            // L1: a member absent from the local library — a comment, never a
61            // dangling path line.
62            let _ = writeln!(out, "# (not in library) {title}");
63            continue;
64        }
65        let path = to_single_line(entry.relative_path);
66        let seconds = extinf_seconds(entry.duration_secs);
67        let _ = write!(out, "#EXTINF:{seconds},{title}\n{path}\n");
68    }
69    out
70}
71
72/// One clip's row in the library index.
73///
74/// The field set is stable and additive: add fields, never rename them. Genuinely
75/// unknown live-only fields are `null` (`Option::None`), never an empty string or
76/// `0`, so a consumer can tell "absent from this run's live feed" from "empty".
77#[derive(Debug, Serialize)]
78struct IndexEntry {
79    id: String,
80    path: String,
81    format: AudioFormat,
82    size: u64,
83    title: String,
84    artist: Option<String>,
85    handle: Option<String>,
86    album: String,
87    root_id: String,
88    created_at: Option<String>,
89    duration: Option<f64>,
90    tags: Option<String>,
91}
92
93/// The serialised shape of the whole-library index.
94#[derive(Debug, Serialize)]
95struct LibraryIndex {
96    schema_version: u32,
97    clips: Vec<IndexEntry>,
98}
99
100/// Render the whole-library index as a stable, pretty-printed JSON document.
101///
102/// One row per `manifest` entry, in clip-id order (the manifest is a `BTreeMap`,
103/// so the order is deterministic), and only clips whose file exists on disk are
104/// listed, so the index never advertises a missing file. Durable fields come
105/// from the manifest and the archived [`LineageStore`]; live-only fields (artist,
106/// handle, duration, tags) come from `live` when the clip was seen this run and
107/// are `null` otherwise. The `album` is the raw logical album title, which
108/// legitimately differs from the sanitised, truncated album segment inside
109/// `path`. The renderer takes no clock, so the output is fully deterministic.
110pub fn render_library_index(
111    manifest: &Manifest,
112    store: &LineageStore,
113    live: &HashMap<&str, &Clip>,
114) -> String {
115    let clips = manifest
116        .iter()
117        .map(|(id, entry)| {
118            let live_clip = live.get(id.as_str()).copied();
119            let title = live_clip
120                .map(|clip| clip.title.clone())
121                .filter(|title| !title.is_empty())
122                .or_else(|| {
123                    store
124                        .node(id)
125                        .map(|node| node.title.clone())
126                        .filter(|title| !title.is_empty())
127                })
128                .unwrap_or_else(|| "Untitled".to_owned());
129            let artist =
130                live_clip.map(|clip| non_empty(&clip.display_name).unwrap_or("Suno").to_owned());
131            let handle = live_clip.and_then(|clip| non_empty(&clip.handle).map(str::to_owned));
132            let album = match live_clip {
133                Some(clip) => store.context_for(clip).album(&clip.title),
134                None => store.album_for_id(id),
135            };
136            let root_id = store
137                .get_root(id)
138                .map(|cached| cached.root_id.clone())
139                .filter(|root| !root.is_empty())
140                .unwrap_or_else(|| id.clone());
141            let created_at = store
142                .node(id)
143                .map(|node| node.created_at.clone())
144                .filter(|created| !created.is_empty());
145            let duration = live_clip.map(|clip| clip.duration);
146            let tags = live_clip.map(|clip| clip.tags.clone());
147            IndexEntry {
148                id: id.clone(),
149                path: entry.path.clone(),
150                format: entry.format,
151                size: entry.size,
152                title,
153                artist,
154                handle,
155                album,
156                root_id,
157                created_at,
158                duration,
159                tags,
160            }
161        })
162        .collect();
163    let index = LibraryIndex {
164        schema_version: INDEX_SCHEMA_VERSION,
165        clips,
166    };
167    serde_json::to_string_pretty(&index).expect("library index serialises")
168}
169
170/// `Some(s)` when `s` is non-empty, else `None`.
171///
172/// Mirrors the tagger's own emptiness test so the index `artist` agrees with the
173/// embedded `ARTIST` tag, including the shared `"Suno"` fallback.
174fn non_empty(s: &str) -> Option<&str> {
175    (!s.is_empty()).then_some(s)
176}
177
178/// Round a duration in seconds to the nearest whole second for `#EXTINF`.
179///
180/// Non-finite inputs fold to `0` so the playlist line stays well-formed.
181fn extinf_seconds(duration_secs: f64) -> i64 {
182    if duration_secs.is_finite() {
183        duration_secs.round() as i64
184    } else {
185        0
186    }
187}
188/// Fold carriage returns and line feeds to spaces, keeping the value on one line
189/// so it cannot break the surrounding text format.
190fn to_single_line(text: &str) -> String {
191    text.replace('\r', "").replace('\n', " ")
192}
193
194/// Render the plain-text per-song details sidecar for `clip`.
195///
196/// The body is a fixed-order block of `Label: value` lines, built from the same
197/// [`TrackMetadata`] that drives the embedded tags (so the dump matches the file
198/// tags), plus the clip id, its duration as `mm:ss`, and the canonical
199/// `https://suno.com/song/<id>` page URL. A field whose value is empty is
200/// omitted, so the output is deterministic and never carries blank labels. The
201/// generation prompt is labelled `Prompt:` (never `Lyrics:` — the lyrics live in
202/// their own sidecar). Because [`TrackMetadata`] carries no URLs, signed CDN
203/// links are excluded automatically, and play-count/liked/trashed/status are not
204/// mapped. Every value is folded to a single line so one field can never break
205/// the block structure.
206pub fn render_clip_details(clip: &Clip, lineage: &LineageContext) -> String {
207    let meta = TrackMetadata::from_clip(clip, lineage);
208    let url = if clip.id.is_empty() {
209        String::new()
210    } else {
211        format!("{SUNO_SONG_BASE_URL}/{}", clip.id)
212    };
213    let fields: [(&str, &str); 17] = [
214        ("Title", &meta.title),
215        ("Artist", &meta.artist),
216        ("Album", &meta.album),
217        ("Album Artist", &meta.album_artist),
218        ("Date", &meta.date),
219        ("Duration", &format_duration(clip.duration)),
220        ("Model", &meta.model),
221        ("Handle", &meta.handle),
222        ("Style", &meta.style),
223        ("Style Summary", &meta.style_summary),
224        ("Comment", &meta.comment),
225        ("Prompt", &clip.prompt),
226        ("Parent", &meta.parent),
227        ("Root", &meta.root),
228        ("Lineage", &meta.lineage),
229        ("Id", &clip.id),
230        ("Url", &url),
231    ];
232    let mut out = String::new();
233    for (label, value) in fields {
234        if value.is_empty() {
235            continue;
236        }
237        let _ = writeln!(out, "{label}: {}", to_single_line(value));
238    }
239    out
240}
241
242/// Render the plain-text lyrics sidecar for `clip`, or `None` when it has none.
243///
244/// The clip's own `lyrics` are emitted verbatim, normalised to exactly one
245/// trailing newline. When the lyrics are empty or whitespace-only, `None` is
246/// returned so no empty `.lyrics.txt` is ever written. The generation prompt is
247/// deliberately not used here (that lives in the details sidecar).
248pub fn render_clip_lyrics(clip: &Clip) -> Option<String> {
249    if clip.lyrics.trim().is_empty() {
250        return None;
251    }
252    Some(format!("{}\n", clip.lyrics.trim_end()))
253}
254
255/// Render an untimed `.lrc` sidecar for `clip`, or `None` when it has no lyrics.
256///
257/// The body carries plain lyric lines with no timestamps. The header block emits
258/// `[ti:]`, `[ar:]`, `[al:]`, and `[length:]` tags, each omitted when its value
259/// is empty or unknown, plus a constant `[re:rs-suno]` tool tag. When the lyrics
260/// are empty or whitespace-only, `None` is returned so no empty `.lrc` is
261/// written.
262pub fn render_clip_lrc(clip: &Clip, lineage: &LineageContext) -> Option<String> {
263    if clip.lyrics.trim().is_empty() {
264        return None;
265    }
266    let meta = TrackMetadata::from_clip(clip, lineage);
267    let length = format_duration(clip.duration);
268    let headers: [(&str, &str); 5] = [
269        ("ti", &meta.title),
270        ("ar", &meta.artist),
271        ("al", &meta.album),
272        ("length", &length),
273        ("re", "rs-suno"),
274    ];
275    let mut out = String::new();
276    for (tag, value) in headers {
277        if value.is_empty() {
278            continue;
279        }
280        let _ = writeln!(out, "[{tag}:{}]", to_single_line(value));
281    }
282    for line in clip.lyrics.trim_end().lines() {
283        let _ = writeln!(out, "{line}");
284    }
285    Some(out)
286}
287
288/// Format a duration in seconds as `mm:ss`, or the empty string when it is
289/// non-finite or non-positive (so an unknown duration is omitted, not `00:00`).
290fn format_duration(secs: f64) -> String {
291    if !secs.is_finite() || secs <= 0.0 {
292        return String::new();
293    }
294    let total = secs.round() as i64;
295    format!("{}:{:02}", total / 60, total % 60)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::lineage::{EdgeType, ResolveStatus};
302
303    fn full_clip() -> Clip {
304        Clip {
305            id: "clip-1234abcd".to_owned(),
306            title: "Electric Storm".to_owned(),
307            tags: "ambient, cinematic".to_owned(),
308            duration: 211.6,
309            created_at: "2024-03-10T14:22:01Z".to_owned(),
310            display_name: "alice".to_owned(),
311            handle: "alice".to_owned(),
312            prompt: "an orchestral storm".to_owned(),
313            gpt_description_prompt: "a moody cinematic build".to_owned(),
314            lyrics: "thunder rolls\nover the plains".to_owned(),
315            model_name: "chirp-v4".to_owned(),
316            major_model_version: "v4".to_owned(),
317            image_large_url: "https://cdn1.suno.ai/signed?token=secret".to_owned(),
318            audio_url: "https://cdn1.suno.ai/clip-1234abcd.mp3".to_owned(),
319            ..Clip::default()
320        }
321    }
322
323    fn full_lineage() -> LineageContext {
324        LineageContext {
325            root_id: "rootid567890".to_owned(),
326            root_title: "Weather Series".to_owned(),
327            parent_id: "parentid1234".to_owned(),
328            edge_type: Some(EdgeType::Extend),
329            status: ResolveStatus::Resolved,
330        }
331    }
332
333    #[test]
334    fn details_render_is_exact_and_fixed_order() {
335        let rendered = render_clip_details(&full_clip(), &full_lineage());
336        let expected = "Title: Electric Storm\n\
337            Artist: alice\n\
338            Album: Weather Series\n\
339            Album Artist: alice\n\
340            Date: 2024-03-10\n\
341            Duration: 3:32\n\
342            Model: chirp-v4 (v4)\n\
343            Handle: alice\n\
344            Style: ambient, cinematic\n\
345            Style Summary: a moody cinematic build\n\
346            Comment: a moody cinematic build\n\
347            Prompt: an orchestral storm\n\
348            Parent: parentid1234\n\
349            Root: rootid567890\n\
350            Lineage: Extended from parentid Root rootid56 (Weather Series)\n\
351            Id: clip-1234abcd\n\
352            Url: https://suno.com/song/clip-1234abcd\n";
353        assert_eq!(rendered, expected);
354    }
355
356    #[test]
357    fn details_omit_empty_fields() {
358        let clip = Clip {
359            id: "only-id".to_owned(),
360            title: "Bare".to_owned(),
361            ..Clip::default()
362        };
363        let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
364        // Only the always-present fields survive: title, artist/album fallbacks,
365        // the self-root id (SUNO_ROOT mirrors the embedded tag), and id/url. No
366        // Date, Duration, Style, Prompt, Parent, or Lineage.
367        let expected = "Title: Bare\n\
368            Artist: Suno\n\
369            Album: Bare\n\
370            Album Artist: Suno\n\
371            Root: only-id\n\
372            Id: only-id\n\
373            Url: https://suno.com/song/only-id\n";
374        assert_eq!(rendered, expected);
375        assert!(!rendered.contains("Duration:"));
376        assert!(!rendered.contains("Prompt:"));
377    }
378
379    #[test]
380    fn details_exclude_signed_cdn_urls() {
381        let rendered = render_clip_details(&full_clip(), &full_lineage());
382        assert!(!rendered.contains("cdn1.suno.ai"));
383        assert!(!rendered.contains("token=secret"));
384        assert!(!rendered.contains(".mp3"));
385    }
386
387    #[test]
388    fn details_use_canonical_song_url() {
389        let rendered = render_clip_details(&full_clip(), &full_lineage());
390        assert!(rendered.contains("Url: https://suno.com/song/clip-1234abcd\n"));
391    }
392
393    #[test]
394    fn details_label_prompt_not_lyrics() {
395        let rendered = render_clip_details(&full_clip(), &full_lineage());
396        assert!(rendered.contains("Prompt: an orchestral storm\n"));
397        // The details dump never labels the generation prompt as lyrics, and it
398        // never carries the actual lyrics.
399        assert!(!rendered.contains("Lyrics:"));
400        assert!(!rendered.contains("thunder rolls"));
401    }
402
403    #[test]
404    fn details_use_resolved_lineage_not_feed_fields() {
405        let clip = Clip {
406            id: "child".to_owned(),
407            title: "Child".to_owned(),
408            album_title: "Ignored Feed Album".to_owned(),
409            ..Clip::default()
410        };
411        let lineage = LineageContext {
412            root_id: "root-01".to_owned(),
413            root_title: "Resolved Album".to_owned(),
414            parent_id: "root-01".to_owned(),
415            edge_type: Some(EdgeType::Cover),
416            status: ResolveStatus::Resolved,
417        };
418        let rendered = render_clip_details(&clip, &lineage);
419        assert!(rendered.contains("Album: Resolved Album\n"));
420        assert!(!rendered.contains("Ignored Feed Album"));
421    }
422
423    #[test]
424    fn details_for_a_pure_root_omit_lineage_and_parent() {
425        let clip = Clip {
426            id: "root".to_owned(),
427            title: "Root".to_owned(),
428            ..Clip::default()
429        };
430        let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
431        // A pure root has no parent edge and no lineage summary; SUNO_ROOT still
432        // mirrors the embedded tag (the clip's own id).
433        assert!(!rendered.contains("Parent:"));
434        assert!(!rendered.contains("Lineage:"));
435        assert!(rendered.contains("Root: root\n"));
436    }
437
438    #[test]
439    fn lyrics_render_verbatim_with_one_trailing_newline() {
440        let clip = Clip {
441            lyrics: "line one\nline two".to_owned(),
442            ..Clip::default()
443        };
444        assert_eq!(
445            render_clip_lyrics(&clip),
446            Some("line one\nline two\n".to_owned())
447        );
448    }
449
450    #[test]
451    fn lyrics_normalise_trailing_whitespace_to_one_newline() {
452        let clip = Clip {
453            lyrics: "verse\n\n\n".to_owned(),
454            ..Clip::default()
455        };
456        assert_eq!(render_clip_lyrics(&clip), Some("verse\n".to_owned()));
457    }
458
459    #[test]
460    fn lyrics_none_when_empty_or_whitespace_only() {
461        assert_eq!(render_clip_lyrics(&Clip::default()), None);
462        let clip = Clip {
463            lyrics: "  \n\t \n".to_owned(),
464            ..Clip::default()
465        };
466        assert_eq!(render_clip_lyrics(&clip), None);
467    }
468
469    #[test]
470    fn lyrics_use_clip_lyrics_not_prompt() {
471        let clip = Clip {
472            prompt: "the generation prompt".to_owned(),
473            lyrics: "the actual sung words".to_owned(),
474            ..Clip::default()
475        };
476        let rendered = render_clip_lyrics(&clip).unwrap();
477        assert!(rendered.contains("the actual sung words"));
478        assert!(!rendered.contains("the generation prompt"));
479    }
480
481    #[test]
482    fn lrc_none_when_lyrics_blank() {
483        let empty = Clip::default();
484        assert_eq!(
485            render_clip_lrc(&empty, &LineageContext::own_root(&empty)),
486            None
487        );
488        let clip = Clip {
489            lyrics: "  \n\t \n".to_owned(),
490            ..Clip::default()
491        };
492        assert_eq!(
493            render_clip_lrc(&clip, &LineageContext::own_root(&clip)),
494            None
495        );
496    }
497
498    #[test]
499    fn lrc_renders_untimed_body_with_headers() {
500        let rendered = render_clip_lrc(&full_clip(), &full_lineage()).unwrap();
501        let expected = "[ti:Electric Storm]\n\
502            [ar:alice]\n\
503            [al:Weather Series]\n\
504            [length:3:32]\n\
505            [re:rs-suno]\n\
506            thunder rolls\n\
507            over the plains\n";
508        assert_eq!(rendered, expected);
509        // Untimed: no per-line `[mm:ss.xx]` timestamps.
510        assert!(!rendered.contains("[00:"));
511    }
512
513    #[test]
514    fn lrc_omits_unknown_headers() {
515        let clip = Clip {
516            title: "Bare".to_owned(),
517            lyrics: "one line".to_owned(),
518            ..Clip::default()
519        };
520        let rendered = render_clip_lrc(&clip, &LineageContext::own_root(&clip)).unwrap();
521        // No duration, so `[length:]` is omitted; artist falls back to Suno and
522        // album to the title. The constant tool tag is always present.
523        assert!(!rendered.contains("[length:"));
524        assert!(rendered.contains("[ti:Bare]\n"));
525        assert!(rendered.contains("[re:rs-suno]\n"));
526        assert!(rendered.ends_with("one line\n"));
527    }
528
529    #[test]
530    fn m3u8_preserves_order_and_rounds_extinf() {
531        let entries = [
532            M3u8Entry {
533                title: "First",
534                duration_secs: 211.6,
535                relative_path: "Artist/Album/First.flac",
536            },
537            M3u8Entry {
538                title: "Second, Take",
539                duration_secs: 90.5,
540                relative_path: "Artist/Album/Second.flac",
541            },
542            M3u8Entry {
543                title: "Third\nLine",
544                duration_secs: 30.2,
545                relative_path: "Artist/Album/Third.flac",
546            },
547        ];
548
549        let rendered = render_m3u8("Road Trip", &entries);
550
551        let expected = "#EXTM3U\n\
552            #PLAYLIST:Road Trip\n\
553            #EXTINF:212,First\n\
554            Artist/Album/First.flac\n\
555            #EXTINF:91,Second, Take\n\
556            Artist/Album/Second.flac\n\
557            #EXTINF:30,Third Line\n\
558            Artist/Album/Third.flac\n";
559        assert_eq!(rendered, expected);
560    }
561
562    #[test]
563    fn m3u8_strips_newlines_but_keeps_commas() {
564        let entries = [M3u8Entry {
565            title: "Hello, World\r\nSecond, Line",
566            duration_secs: 12.0,
567            relative_path: "Artist/Track.flac",
568        }];
569
570        let rendered = render_m3u8("Mix", &entries);
571
572        assert_eq!(
573            rendered,
574            "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:12,Hello, World Second, Line\nArtist/Track.flac\n"
575        );
576        assert!(!rendered.contains('\r'));
577        // Header, playlist name, one EXTINF line, one path line, trailing newline.
578        assert_eq!(rendered.lines().count(), 4);
579    }
580
581    #[test]
582    fn m3u8_folds_newlines_in_the_playlist_name() {
583        let rendered = render_m3u8("Road\r\nTrip", &[]);
584        assert_eq!(rendered, "#EXTM3U\n#PLAYLIST:Road Trip\n");
585    }
586
587    #[test]
588    fn m3u8_empty_list_is_header_and_name_only() {
589        assert_eq!(render_m3u8("Empty", &[]), "#EXTM3U\n#PLAYLIST:Empty\n");
590    }
591
592    #[test]
593    fn m3u8_absent_member_renders_a_comment_not_a_path() {
594        // L1: an empty relative path means the member is not in the local
595        // library, so it is a comment line with no #EXTINF and no path.
596        let entries = [
597            M3u8Entry {
598                title: "In Library",
599                duration_secs: 60.0,
600                relative_path: "Artist/In.flac",
601            },
602            M3u8Entry {
603                title: "Missing, Song",
604                duration_secs: 42.0,
605                relative_path: "",
606            },
607            M3u8Entry {
608                title: "Also Present",
609                duration_secs: 30.0,
610                relative_path: "Artist/Also.flac",
611            },
612        ];
613
614        let rendered = render_m3u8("Liked Songs", &entries);
615
616        let expected = "#EXTM3U\n\
617            #PLAYLIST:Liked Songs\n\
618            #EXTINF:60,In Library\n\
619            Artist/In.flac\n\
620            # (not in library) Missing, Song\n\
621            #EXTINF:30,Also Present\n\
622            Artist/Also.flac\n";
623        assert_eq!(rendered, expected);
624        // The absent member never contributes a bare path line.
625        assert!(!rendered.contains("#EXTINF:42"));
626    }
627
628    #[test]
629    fn m3u8_non_finite_duration_is_zero() {
630        let entries = [M3u8Entry {
631            title: "Unknown",
632            duration_secs: f64::NAN,
633            relative_path: "Artist/Unknown.flac",
634        }];
635
636        assert_eq!(
637            render_m3u8("Odd", &entries),
638            "#EXTM3U\n#PLAYLIST:Odd\n#EXTINF:0,Unknown\nArtist/Unknown.flac\n"
639        );
640    }
641
642    use crate::lineage::{Resolution, RootInfo};
643    use crate::manifest::ManifestEntry;
644    use serde_json::Value;
645    use std::collections::HashMap as Map;
646
647    fn manifest_entry(path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
648        ManifestEntry {
649            path: path.to_owned(),
650            format,
651            size,
652            ..Default::default()
653        }
654    }
655
656    fn clip(id: &str, title: &str) -> Clip {
657        Clip {
658            id: id.to_owned(),
659            title: title.to_owned(),
660            display_name: "alice".to_owned(),
661            handle: "alice_handle".to_owned(),
662            tags: "ambient, cinematic".to_owned(),
663            duration: 211.0,
664            created_at: "2024-03-10T14:22:01Z".to_owned(),
665            ..Default::default()
666        }
667    }
668
669    /// A store where `child` is a cover rooted at `root` ("Original"), both nodes
670    /// archived with created-at dates.
671    fn lineage_store() -> LineageStore {
672        let child = Clip {
673            id: "child".to_owned(),
674            title: "Cover Take".to_owned(),
675            created_at: "2024-05-01T00:00:00Z".to_owned(),
676            clip_type: "gen".to_owned(),
677            task: "cover".to_owned(),
678            cover_clip_id: "root".to_owned(),
679            edited_clip_id: "root".to_owned(),
680            ..Default::default()
681        };
682        let root = Clip {
683            id: "root".to_owned(),
684            title: "Original".to_owned(),
685            created_at: "2024-04-01T00:00:00Z".to_owned(),
686            ..Default::default()
687        };
688        let mut roots = HashMap::new();
689        roots.insert(
690            "child".to_owned(),
691            RootInfo {
692                root_id: "root".to_owned(),
693                root_title: "Original".to_owned(),
694                status: ResolveStatus::Resolved,
695            },
696        );
697        roots.insert(
698            "root".to_owned(),
699            RootInfo {
700                root_id: "root".to_owned(),
701                root_title: "Original".to_owned(),
702                status: ResolveStatus::Resolved,
703            },
704        );
705        let resolution = Resolution {
706            roots,
707            gap_filled: Vec::new(),
708        };
709        let mut store = LineageStore::new();
710        store.update(&[child, root], &resolution, "2024-06-01T00:00:00Z");
711        store
712    }
713
714    fn parse(rendered: &str) -> Value {
715        serde_json::from_str(rendered).expect("index is valid JSON")
716    }
717
718    #[test]
719    fn index_empty_manifest_is_exact() {
720        let rendered = render_library_index(&Manifest::new(), &LineageStore::new(), &Map::new());
721        assert_eq!(rendered, "{\n  \"schema_version\": 1,\n  \"clips\": []\n}");
722    }
723
724    #[test]
725    fn index_schema_version_matches_constant() {
726        let value = parse(&render_library_index(
727            &Manifest::new(),
728            &LineageStore::new(),
729            &Map::new(),
730        ));
731        assert_eq!(value["schema_version"], INDEX_SCHEMA_VERSION);
732    }
733
734    #[test]
735    fn index_live_clip_uses_live_fields_and_canonical_album() {
736        let mut manifest = Manifest::new();
737        manifest.insert(
738            "child",
739            manifest_entry("Original/Cover Take.flac", AudioFormat::Flac, 99),
740        );
741        let store = lineage_store();
742        let clip = clip("child", "Cover Take");
743        let mut live: Map<&str, &Clip> = Map::new();
744        live.insert("child", &clip);
745
746        let value = parse(&render_library_index(&manifest, &store, &live));
747        let row = &value["clips"][0];
748        assert_eq!(row["id"], "child");
749        assert_eq!(row["path"], "Original/Cover Take.flac");
750        assert_eq!(row["format"], "flac");
751        assert_eq!(row["size"], 99);
752        assert_eq!(row["title"], "Cover Take");
753        assert_eq!(row["artist"], "alice");
754        assert_eq!(row["handle"], "alice_handle");
755        // Canonical album is the resolved root's title, matching context_for.
756        assert_eq!(row["album"], "Original");
757        assert_eq!(row["root_id"], "root");
758        assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
759        assert_eq!(row["duration"], 211.0);
760        assert_eq!(row["tags"], "ambient, cinematic");
761    }
762
763    #[test]
764    fn index_on_disk_clip_nulls_live_only_fields() {
765        let mut manifest = Manifest::new();
766        manifest.insert(
767            "child",
768            manifest_entry("Original/Cover Take.flac", AudioFormat::Mp3, 7),
769        );
770        let store = lineage_store();
771
772        // The clip is not in this run's live set.
773        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
774        let row = &value["clips"][0];
775        // Durable fields are still present, sourced from manifest and store.
776        assert_eq!(row["format"], "mp3");
777        assert_eq!(row["size"], 7);
778        assert_eq!(row["title"], "Cover Take");
779        assert_eq!(row["album"], "Original");
780        assert_eq!(row["root_id"], "root");
781        assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
782        // Live-only fields are null, never empty-string or zero.
783        assert!(row["artist"].is_null());
784        assert!(row["handle"].is_null());
785        assert!(row["duration"].is_null());
786        assert!(row["tags"].is_null());
787    }
788
789    #[test]
790    fn index_album_resolves_for_both_live_and_on_disk_clips() {
791        let mut manifest = Manifest::new();
792        manifest.insert(
793            "child",
794            manifest_entry("Original/a.flac", AudioFormat::Flac, 1),
795        );
796        let store = lineage_store();
797
798        let live_clip = clip("child", "Cover Take");
799        let mut live: Map<&str, &Clip> = Map::new();
800        live.insert("child", &live_clip);
801        let live_value = parse(&render_library_index(&manifest, &store, &live));
802        let on_disk_value = parse(&render_library_index(&manifest, &store, &Map::new()));
803
804        // Both paths derive the same canonical album via the shared rule.
805        assert_eq!(live_value["clips"][0]["album"], "Original");
806        assert_eq!(on_disk_value["clips"][0]["album"], "Original");
807    }
808
809    #[test]
810    fn index_album_differs_from_sanitised_path_segment() {
811        // The path album segment is sanitised and truncated; the index album is
812        // the raw logical title, so they legitimately differ. Here the root
813        // title carries a slash that naming would never leave in a folder name.
814        let mut manifest = Manifest::new();
815        manifest.insert(
816            "child",
817            manifest_entry("AC-DC Live/song.flac", AudioFormat::Flac, 1),
818        );
819        let raw_root = Clip {
820            id: "root".to_owned(),
821            title: "AC/DC: Live!".to_owned(),
822            created_at: "2024-04-01T00:00:00Z".to_owned(),
823            ..Default::default()
824        };
825        let child = Clip {
826            id: "child".to_owned(),
827            title: "song".to_owned(),
828            clip_type: "gen".to_owned(),
829            task: "cover".to_owned(),
830            cover_clip_id: "root".to_owned(),
831            edited_clip_id: "root".to_owned(),
832            ..Default::default()
833        };
834        let mut roots = HashMap::new();
835        roots.insert(
836            "child".to_owned(),
837            RootInfo {
838                root_id: "root".to_owned(),
839                root_title: "AC/DC: Live!".to_owned(),
840                status: ResolveStatus::Resolved,
841            },
842        );
843        let resolution = Resolution {
844            roots,
845            gap_filled: Vec::new(),
846        };
847        let mut store = LineageStore::new();
848        store.update(&[child, raw_root], &resolution, "2024-06-01T00:00:00Z");
849
850        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
851        let row = &value["clips"][0];
852        assert_eq!(row["album"], "AC/DC: Live!");
853        // The path segment is the sanitised, slash-free folder.
854        let album = row["album"].as_str().unwrap();
855        let path_segment = row["path"].as_str().unwrap().split('/').next().unwrap();
856        assert_ne!(album, path_segment);
857        assert_eq!(path_segment, "AC-DC Live");
858    }
859
860    #[test]
861    fn index_iterates_in_clip_id_order() {
862        let mut manifest = Manifest::new();
863        manifest.insert("c", manifest_entry("c.flac", AudioFormat::Flac, 1));
864        manifest.insert("a", manifest_entry("a.flac", AudioFormat::Flac, 1));
865        manifest.insert("b", manifest_entry("b.flac", AudioFormat::Flac, 1));
866
867        let value = parse(&render_library_index(
868            &manifest,
869            &LineageStore::new(),
870            &Map::new(),
871        ));
872        let ids: Vec<&str> = value["clips"]
873            .as_array()
874            .unwrap()
875            .iter()
876            .map(|row| row["id"].as_str().unwrap())
877            .collect();
878        assert_eq!(ids, ["a", "b", "c"]);
879    }
880
881    #[test]
882    fn index_unknown_clip_is_well_formed_with_defaults() {
883        // A manifest id absent from both live and the store nodes: self-root,
884        // "Untitled", null live fields, no panic.
885        let mut manifest = Manifest::new();
886        manifest.insert("orphan", manifest_entry("orphan.wav", AudioFormat::Wav, 3));
887
888        let value = parse(&render_library_index(
889            &manifest,
890            &LineageStore::new(),
891            &Map::new(),
892        ));
893        let row = &value["clips"][0];
894        assert_eq!(row["id"], "orphan");
895        assert_eq!(row["title"], "Untitled");
896        assert_eq!(row["format"], "wav");
897        assert_eq!(row["album"], "");
898        assert_eq!(row["root_id"], "orphan");
899        assert!(row["created_at"].is_null());
900        assert!(row["artist"].is_null());
901        assert!(row["tags"].is_null());
902    }
903
904    #[test]
905    fn index_title_falls_back_to_store_node_then_untitled() {
906        let mut manifest = Manifest::new();
907        manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
908        let store = lineage_store();
909        // No live clip, so title comes from the archived node.
910        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
911        assert_eq!(value["clips"][0]["title"], "Cover Take");
912    }
913
914    #[test]
915    fn index_artist_falls_back_to_suno_when_display_name_empty() {
916        let mut manifest = Manifest::new();
917        manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
918        let mut anon = clip("child", "Cover Take");
919        anon.display_name = String::new();
920        let mut live: Map<&str, &Clip> = Map::new();
921        live.insert("child", &anon);
922        let value = parse(&render_library_index(
923            &manifest,
924            &LineageStore::new(),
925            &live,
926        ));
927        // Matches TrackMetadata::from_clip's "Suno" fallback for the ARTIST tag.
928        assert_eq!(value["clips"][0]["artist"], "Suno");
929    }
930
931    #[test]
932    fn index_unicode_round_trips() {
933        let mut manifest = Manifest::new();
934        manifest.insert("🎵", manifest_entry("音楽/曲.flac", AudioFormat::Flac, 5));
935        let unicode = clip("🎵", "音楽 \"quoted\"");
936        let mut live: Map<&str, &Clip> = Map::new();
937        live.insert("🎵", &unicode);
938
939        let rendered = render_library_index(&manifest, &LineageStore::new(), &live);
940        let value = parse(&rendered);
941        let row = &value["clips"][0];
942        assert_eq!(row["id"], "🎵");
943        assert_eq!(row["path"], "音楽/曲.flac");
944        assert_eq!(row["title"], "音楽 \"quoted\"");
945    }
946}