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", &meta.lyrics),
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/// Format a duration in seconds as `mm:ss`, or the empty string when it is
256/// non-finite or non-positive (so an unknown duration is omitted, not `00:00`).
257fn format_duration(secs: f64) -> String {
258    if !secs.is_finite() || secs <= 0.0 {
259        return String::new();
260    }
261    let total = secs.round() as i64;
262    format!("{}:{:02}", total / 60, total % 60)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::lineage::{EdgeType, ResolveStatus};
269
270    fn full_clip() -> Clip {
271        Clip {
272            id: "clip-1234abcd".to_owned(),
273            title: "Electric Storm".to_owned(),
274            tags: "ambient, cinematic".to_owned(),
275            duration: 211.6,
276            created_at: "2024-03-10T14:22:01Z".to_owned(),
277            display_name: "alice".to_owned(),
278            handle: "alice".to_owned(),
279            prompt: "an orchestral storm".to_owned(),
280            gpt_description_prompt: "a moody cinematic build".to_owned(),
281            lyrics: "thunder rolls\nover the plains".to_owned(),
282            model_name: "chirp-v4".to_owned(),
283            major_model_version: "v4".to_owned(),
284            image_large_url: "https://cdn1.suno.ai/signed?token=secret".to_owned(),
285            audio_url: "https://cdn1.suno.ai/clip-1234abcd.mp3".to_owned(),
286            ..Clip::default()
287        }
288    }
289
290    fn full_lineage() -> LineageContext {
291        LineageContext {
292            root_id: "rootid567890".to_owned(),
293            root_title: "Weather Series".to_owned(),
294            parent_id: "parentid1234".to_owned(),
295            edge_type: Some(EdgeType::Extend),
296            status: ResolveStatus::Resolved,
297        }
298    }
299
300    #[test]
301    fn details_render_is_exact_and_fixed_order() {
302        let rendered = render_clip_details(&full_clip(), &full_lineage());
303        let expected = "Title: Electric Storm\n\
304            Artist: alice\n\
305            Album: Weather Series\n\
306            Album Artist: alice\n\
307            Date: 2024-03-10\n\
308            Duration: 3:32\n\
309            Model: chirp-v4 (v4)\n\
310            Handle: alice\n\
311            Style: ambient, cinematic\n\
312            Style Summary: a moody cinematic build\n\
313            Comment: a moody cinematic build\n\
314            Prompt: an orchestral storm\n\
315            Parent: parentid1234\n\
316            Root: rootid567890\n\
317            Lineage: Extended from parentid Root rootid56 (Weather Series)\n\
318            Id: clip-1234abcd\n\
319            Url: https://suno.com/song/clip-1234abcd\n";
320        assert_eq!(rendered, expected);
321    }
322
323    #[test]
324    fn details_omit_empty_fields() {
325        let clip = Clip {
326            id: "only-id".to_owned(),
327            title: "Bare".to_owned(),
328            ..Clip::default()
329        };
330        let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
331        // Only the always-present fields survive: title, artist/album fallbacks,
332        // the self-root id (SUNO_ROOT mirrors the embedded tag), and id/url. No
333        // Date, Duration, Style, Prompt, Parent, or Lineage.
334        let expected = "Title: Bare\n\
335            Artist: Suno\n\
336            Album: Bare\n\
337            Album Artist: Suno\n\
338            Root: only-id\n\
339            Id: only-id\n\
340            Url: https://suno.com/song/only-id\n";
341        assert_eq!(rendered, expected);
342        assert!(!rendered.contains("Duration:"));
343        assert!(!rendered.contains("Prompt:"));
344    }
345
346    #[test]
347    fn details_exclude_signed_cdn_urls() {
348        let rendered = render_clip_details(&full_clip(), &full_lineage());
349        assert!(!rendered.contains("cdn1.suno.ai"));
350        assert!(!rendered.contains("token=secret"));
351        assert!(!rendered.contains(".mp3"));
352    }
353
354    #[test]
355    fn details_use_canonical_song_url() {
356        let rendered = render_clip_details(&full_clip(), &full_lineage());
357        assert!(rendered.contains("Url: https://suno.com/song/clip-1234abcd\n"));
358    }
359
360    #[test]
361    fn details_label_prompt_not_lyrics() {
362        let rendered = render_clip_details(&full_clip(), &full_lineage());
363        assert!(rendered.contains("Prompt: an orchestral storm\n"));
364        // The details dump never labels the generation prompt as lyrics, and it
365        // never carries the actual lyrics.
366        assert!(!rendered.contains("Lyrics:"));
367        assert!(!rendered.contains("thunder rolls"));
368    }
369
370    #[test]
371    fn details_use_resolved_lineage_not_feed_fields() {
372        let clip = Clip {
373            id: "child".to_owned(),
374            title: "Child".to_owned(),
375            album_title: "Ignored Feed Album".to_owned(),
376            ..Clip::default()
377        };
378        let lineage = LineageContext {
379            root_id: "root-01".to_owned(),
380            root_title: "Resolved Album".to_owned(),
381            parent_id: "root-01".to_owned(),
382            edge_type: Some(EdgeType::Cover),
383            status: ResolveStatus::Resolved,
384        };
385        let rendered = render_clip_details(&clip, &lineage);
386        assert!(rendered.contains("Album: Resolved Album\n"));
387        assert!(!rendered.contains("Ignored Feed Album"));
388    }
389
390    #[test]
391    fn details_for_a_pure_root_omit_lineage_and_parent() {
392        let clip = Clip {
393            id: "root".to_owned(),
394            title: "Root".to_owned(),
395            ..Clip::default()
396        };
397        let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
398        // A pure root has no parent edge and no lineage summary; SUNO_ROOT still
399        // mirrors the embedded tag (the clip's own id).
400        assert!(!rendered.contains("Parent:"));
401        assert!(!rendered.contains("Lineage:"));
402        assert!(rendered.contains("Root: root\n"));
403    }
404
405    #[test]
406    fn lyrics_render_verbatim_with_one_trailing_newline() {
407        let clip = Clip {
408            lyrics: "line one\nline two".to_owned(),
409            ..Clip::default()
410        };
411        assert_eq!(
412            render_clip_lyrics(&clip),
413            Some("line one\nline two\n".to_owned())
414        );
415    }
416
417    #[test]
418    fn lyrics_normalise_trailing_whitespace_to_one_newline() {
419        let clip = Clip {
420            lyrics: "verse\n\n\n".to_owned(),
421            ..Clip::default()
422        };
423        assert_eq!(render_clip_lyrics(&clip), Some("verse\n".to_owned()));
424    }
425
426    #[test]
427    fn lyrics_none_when_empty_or_whitespace_only() {
428        assert_eq!(render_clip_lyrics(&Clip::default()), None);
429        let clip = Clip {
430            lyrics: "  \n\t \n".to_owned(),
431            ..Clip::default()
432        };
433        assert_eq!(render_clip_lyrics(&clip), None);
434    }
435
436    #[test]
437    fn lyrics_use_clip_lyrics_not_prompt() {
438        let clip = Clip {
439            prompt: "the generation prompt".to_owned(),
440            lyrics: "the actual sung words".to_owned(),
441            ..Clip::default()
442        };
443        let rendered = render_clip_lyrics(&clip).unwrap();
444        assert!(rendered.contains("the actual sung words"));
445        assert!(!rendered.contains("the generation prompt"));
446    }
447
448    #[test]
449    fn m3u8_preserves_order_and_rounds_extinf() {
450        let entries = [
451            M3u8Entry {
452                title: "First",
453                duration_secs: 211.6,
454                relative_path: "Artist/Album/First.flac",
455            },
456            M3u8Entry {
457                title: "Second, Take",
458                duration_secs: 90.5,
459                relative_path: "Artist/Album/Second.flac",
460            },
461            M3u8Entry {
462                title: "Third\nLine",
463                duration_secs: 30.2,
464                relative_path: "Artist/Album/Third.flac",
465            },
466        ];
467
468        let rendered = render_m3u8("Road Trip", &entries);
469
470        let expected = "#EXTM3U\n\
471            #PLAYLIST:Road Trip\n\
472            #EXTINF:212,First\n\
473            Artist/Album/First.flac\n\
474            #EXTINF:91,Second, Take\n\
475            Artist/Album/Second.flac\n\
476            #EXTINF:30,Third Line\n\
477            Artist/Album/Third.flac\n";
478        assert_eq!(rendered, expected);
479    }
480
481    #[test]
482    fn m3u8_strips_newlines_but_keeps_commas() {
483        let entries = [M3u8Entry {
484            title: "Hello, World\r\nSecond, Line",
485            duration_secs: 12.0,
486            relative_path: "Artist/Track.flac",
487        }];
488
489        let rendered = render_m3u8("Mix", &entries);
490
491        assert_eq!(
492            rendered,
493            "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:12,Hello, World Second, Line\nArtist/Track.flac\n"
494        );
495        assert!(!rendered.contains('\r'));
496        // Header, playlist name, one EXTINF line, one path line, trailing newline.
497        assert_eq!(rendered.lines().count(), 4);
498    }
499
500    #[test]
501    fn m3u8_folds_newlines_in_the_playlist_name() {
502        let rendered = render_m3u8("Road\r\nTrip", &[]);
503        assert_eq!(rendered, "#EXTM3U\n#PLAYLIST:Road Trip\n");
504    }
505
506    #[test]
507    fn m3u8_empty_list_is_header_and_name_only() {
508        assert_eq!(render_m3u8("Empty", &[]), "#EXTM3U\n#PLAYLIST:Empty\n");
509    }
510
511    #[test]
512    fn m3u8_absent_member_renders_a_comment_not_a_path() {
513        // L1: an empty relative path means the member is not in the local
514        // library, so it is a comment line with no #EXTINF and no path.
515        let entries = [
516            M3u8Entry {
517                title: "In Library",
518                duration_secs: 60.0,
519                relative_path: "Artist/In.flac",
520            },
521            M3u8Entry {
522                title: "Missing, Song",
523                duration_secs: 42.0,
524                relative_path: "",
525            },
526            M3u8Entry {
527                title: "Also Present",
528                duration_secs: 30.0,
529                relative_path: "Artist/Also.flac",
530            },
531        ];
532
533        let rendered = render_m3u8("Liked Songs", &entries);
534
535        let expected = "#EXTM3U\n\
536            #PLAYLIST:Liked Songs\n\
537            #EXTINF:60,In Library\n\
538            Artist/In.flac\n\
539            # (not in library) Missing, Song\n\
540            #EXTINF:30,Also Present\n\
541            Artist/Also.flac\n";
542        assert_eq!(rendered, expected);
543        // The absent member never contributes a bare path line.
544        assert!(!rendered.contains("#EXTINF:42"));
545    }
546
547    #[test]
548    fn m3u8_non_finite_duration_is_zero() {
549        let entries = [M3u8Entry {
550            title: "Unknown",
551            duration_secs: f64::NAN,
552            relative_path: "Artist/Unknown.flac",
553        }];
554
555        assert_eq!(
556            render_m3u8("Odd", &entries),
557            "#EXTM3U\n#PLAYLIST:Odd\n#EXTINF:0,Unknown\nArtist/Unknown.flac\n"
558        );
559    }
560
561    use crate::lineage::{Resolution, RootInfo};
562    use crate::manifest::ManifestEntry;
563    use serde_json::Value;
564    use std::collections::HashMap as Map;
565
566    fn manifest_entry(path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
567        ManifestEntry {
568            path: path.to_owned(),
569            format,
570            size,
571            ..Default::default()
572        }
573    }
574
575    fn clip(id: &str, title: &str) -> Clip {
576        Clip {
577            id: id.to_owned(),
578            title: title.to_owned(),
579            display_name: "alice".to_owned(),
580            handle: "alice_handle".to_owned(),
581            tags: "ambient, cinematic".to_owned(),
582            duration: 211.0,
583            created_at: "2024-03-10T14:22:01Z".to_owned(),
584            ..Default::default()
585        }
586    }
587
588    /// A store where `child` is a cover rooted at `root` ("Original"), both nodes
589    /// archived with created-at dates.
590    fn lineage_store() -> LineageStore {
591        let child = Clip {
592            id: "child".to_owned(),
593            title: "Cover Take".to_owned(),
594            created_at: "2024-05-01T00:00:00Z".to_owned(),
595            clip_type: "gen".to_owned(),
596            task: "cover".to_owned(),
597            cover_clip_id: "root".to_owned(),
598            edited_clip_id: "root".to_owned(),
599            ..Default::default()
600        };
601        let root = Clip {
602            id: "root".to_owned(),
603            title: "Original".to_owned(),
604            created_at: "2024-04-01T00:00:00Z".to_owned(),
605            ..Default::default()
606        };
607        let mut roots = HashMap::new();
608        roots.insert(
609            "child".to_owned(),
610            RootInfo {
611                root_id: "root".to_owned(),
612                root_title: "Original".to_owned(),
613                status: ResolveStatus::Resolved,
614            },
615        );
616        roots.insert(
617            "root".to_owned(),
618            RootInfo {
619                root_id: "root".to_owned(),
620                root_title: "Original".to_owned(),
621                status: ResolveStatus::Resolved,
622            },
623        );
624        let resolution = Resolution {
625            roots,
626            gap_filled: Vec::new(),
627        };
628        let mut store = LineageStore::new();
629        store.update(&[child, root], &resolution, "2024-06-01T00:00:00Z");
630        store
631    }
632
633    fn parse(rendered: &str) -> Value {
634        serde_json::from_str(rendered).expect("index is valid JSON")
635    }
636
637    #[test]
638    fn index_empty_manifest_is_exact() {
639        let rendered = render_library_index(&Manifest::new(), &LineageStore::new(), &Map::new());
640        assert_eq!(rendered, "{\n  \"schema_version\": 1,\n  \"clips\": []\n}");
641    }
642
643    #[test]
644    fn index_schema_version_matches_constant() {
645        let value = parse(&render_library_index(
646            &Manifest::new(),
647            &LineageStore::new(),
648            &Map::new(),
649        ));
650        assert_eq!(value["schema_version"], INDEX_SCHEMA_VERSION);
651    }
652
653    #[test]
654    fn index_live_clip_uses_live_fields_and_canonical_album() {
655        let mut manifest = Manifest::new();
656        manifest.insert(
657            "child",
658            manifest_entry("Original/Cover Take.flac", AudioFormat::Flac, 99),
659        );
660        let store = lineage_store();
661        let clip = clip("child", "Cover Take");
662        let mut live: Map<&str, &Clip> = Map::new();
663        live.insert("child", &clip);
664
665        let value = parse(&render_library_index(&manifest, &store, &live));
666        let row = &value["clips"][0];
667        assert_eq!(row["id"], "child");
668        assert_eq!(row["path"], "Original/Cover Take.flac");
669        assert_eq!(row["format"], "flac");
670        assert_eq!(row["size"], 99);
671        assert_eq!(row["title"], "Cover Take");
672        assert_eq!(row["artist"], "alice");
673        assert_eq!(row["handle"], "alice_handle");
674        // Canonical album is the resolved root's title, matching context_for.
675        assert_eq!(row["album"], "Original");
676        assert_eq!(row["root_id"], "root");
677        assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
678        assert_eq!(row["duration"], 211.0);
679        assert_eq!(row["tags"], "ambient, cinematic");
680    }
681
682    #[test]
683    fn index_on_disk_clip_nulls_live_only_fields() {
684        let mut manifest = Manifest::new();
685        manifest.insert(
686            "child",
687            manifest_entry("Original/Cover Take.flac", AudioFormat::Mp3, 7),
688        );
689        let store = lineage_store();
690
691        // The clip is not in this run's live set.
692        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
693        let row = &value["clips"][0];
694        // Durable fields are still present, sourced from manifest and store.
695        assert_eq!(row["format"], "mp3");
696        assert_eq!(row["size"], 7);
697        assert_eq!(row["title"], "Cover Take");
698        assert_eq!(row["album"], "Original");
699        assert_eq!(row["root_id"], "root");
700        assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
701        // Live-only fields are null, never empty-string or zero.
702        assert!(row["artist"].is_null());
703        assert!(row["handle"].is_null());
704        assert!(row["duration"].is_null());
705        assert!(row["tags"].is_null());
706    }
707
708    #[test]
709    fn index_album_resolves_for_both_live_and_on_disk_clips() {
710        let mut manifest = Manifest::new();
711        manifest.insert(
712            "child",
713            manifest_entry("Original/a.flac", AudioFormat::Flac, 1),
714        );
715        let store = lineage_store();
716
717        let live_clip = clip("child", "Cover Take");
718        let mut live: Map<&str, &Clip> = Map::new();
719        live.insert("child", &live_clip);
720        let live_value = parse(&render_library_index(&manifest, &store, &live));
721        let on_disk_value = parse(&render_library_index(&manifest, &store, &Map::new()));
722
723        // Both paths derive the same canonical album via the shared rule.
724        assert_eq!(live_value["clips"][0]["album"], "Original");
725        assert_eq!(on_disk_value["clips"][0]["album"], "Original");
726    }
727
728    #[test]
729    fn index_album_differs_from_sanitised_path_segment() {
730        // The path album segment is sanitised and truncated; the index album is
731        // the raw logical title, so they legitimately differ. Here the root
732        // title carries a slash that naming would never leave in a folder name.
733        let mut manifest = Manifest::new();
734        manifest.insert(
735            "child",
736            manifest_entry("AC-DC Live/song.flac", AudioFormat::Flac, 1),
737        );
738        let raw_root = Clip {
739            id: "root".to_owned(),
740            title: "AC/DC: Live!".to_owned(),
741            created_at: "2024-04-01T00:00:00Z".to_owned(),
742            ..Default::default()
743        };
744        let child = Clip {
745            id: "child".to_owned(),
746            title: "song".to_owned(),
747            clip_type: "gen".to_owned(),
748            task: "cover".to_owned(),
749            cover_clip_id: "root".to_owned(),
750            edited_clip_id: "root".to_owned(),
751            ..Default::default()
752        };
753        let mut roots = HashMap::new();
754        roots.insert(
755            "child".to_owned(),
756            RootInfo {
757                root_id: "root".to_owned(),
758                root_title: "AC/DC: Live!".to_owned(),
759                status: ResolveStatus::Resolved,
760            },
761        );
762        let resolution = Resolution {
763            roots,
764            gap_filled: Vec::new(),
765        };
766        let mut store = LineageStore::new();
767        store.update(&[child, raw_root], &resolution, "2024-06-01T00:00:00Z");
768
769        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
770        let row = &value["clips"][0];
771        assert_eq!(row["album"], "AC/DC: Live!");
772        // The path segment is the sanitised, slash-free folder.
773        let album = row["album"].as_str().unwrap();
774        let path_segment = row["path"].as_str().unwrap().split('/').next().unwrap();
775        assert_ne!(album, path_segment);
776        assert_eq!(path_segment, "AC-DC Live");
777    }
778
779    #[test]
780    fn index_iterates_in_clip_id_order() {
781        let mut manifest = Manifest::new();
782        manifest.insert("c", manifest_entry("c.flac", AudioFormat::Flac, 1));
783        manifest.insert("a", manifest_entry("a.flac", AudioFormat::Flac, 1));
784        manifest.insert("b", manifest_entry("b.flac", AudioFormat::Flac, 1));
785
786        let value = parse(&render_library_index(
787            &manifest,
788            &LineageStore::new(),
789            &Map::new(),
790        ));
791        let ids: Vec<&str> = value["clips"]
792            .as_array()
793            .unwrap()
794            .iter()
795            .map(|row| row["id"].as_str().unwrap())
796            .collect();
797        assert_eq!(ids, ["a", "b", "c"]);
798    }
799
800    #[test]
801    fn index_unknown_clip_is_well_formed_with_defaults() {
802        // A manifest id absent from both live and the store nodes: self-root,
803        // "Untitled", null live fields, no panic.
804        let mut manifest = Manifest::new();
805        manifest.insert("orphan", manifest_entry("orphan.wav", AudioFormat::Wav, 3));
806
807        let value = parse(&render_library_index(
808            &manifest,
809            &LineageStore::new(),
810            &Map::new(),
811        ));
812        let row = &value["clips"][0];
813        assert_eq!(row["id"], "orphan");
814        assert_eq!(row["title"], "Untitled");
815        assert_eq!(row["format"], "wav");
816        assert_eq!(row["album"], "");
817        assert_eq!(row["root_id"], "orphan");
818        assert!(row["created_at"].is_null());
819        assert!(row["artist"].is_null());
820        assert!(row["tags"].is_null());
821    }
822
823    #[test]
824    fn index_title_falls_back_to_store_node_then_untitled() {
825        let mut manifest = Manifest::new();
826        manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
827        let store = lineage_store();
828        // No live clip, so title comes from the archived node.
829        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
830        assert_eq!(value["clips"][0]["title"], "Cover Take");
831    }
832
833    #[test]
834    fn index_artist_falls_back_to_suno_when_display_name_empty() {
835        let mut manifest = Manifest::new();
836        manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
837        let mut anon = clip("child", "Cover Take");
838        anon.display_name = String::new();
839        let mut live: Map<&str, &Clip> = Map::new();
840        live.insert("child", &anon);
841        let value = parse(&render_library_index(
842            &manifest,
843            &LineageStore::new(),
844            &live,
845        ));
846        // Matches TrackMetadata::from_clip's "Suno" fallback for the ARTIST tag.
847        assert_eq!(value["clips"][0]["artist"], "Suno");
848    }
849
850    #[test]
851    fn index_unicode_round_trips() {
852        let mut manifest = Manifest::new();
853        manifest.insert("🎵", manifest_entry("音楽/曲.flac", AudioFormat::Flac, 5));
854        let unicode = clip("🎵", "音楽 \"quoted\"");
855        let mut live: Map<&str, &Clip> = Map::new();
856        live.insert("🎵", &unicode);
857
858        let rendered = render_library_index(&manifest, &LineageStore::new(), &live);
859        let value = parse(&rendered);
860        let row = &value["clips"][0];
861        assert_eq!(row["id"], "🎵");
862        assert_eq!(row["path"], "音楽/曲.flac");
863        assert_eq!(row["title"], "音楽 \"quoted\"");
864    }
865}