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