Skip to main content

suno_core/
extras.rs

1//! Pure "media extras" generators: M3U8 playlists and the library index.
2//!
3//! Every function here is pure. It takes clip data plus relative paths and
4//! returns the text the CLI writes to disk later, with no IO, no clock, and no
5//! network, so the logic stays deterministic and is unit-tested in isolation.
6
7use std::collections::HashMap;
8use std::fmt::Write as _;
9
10use serde::Serialize;
11
12use crate::config::AudioFormat;
13use crate::graph::LineageStore;
14use crate::manifest::Manifest;
15use crate::model::Clip;
16
17/// The schema version of the library index document.
18///
19/// Bump this only when the index shape changes. The field set is additive:
20/// fields may be added, never renamed or repurposed.
21pub const INDEX_SCHEMA_VERSION: u32 = 1;
22
23/// One ordered entry in an extended-M3U8 playlist.
24///
25/// Order is significant: the liked and playlist ordering is preserved exactly
26/// as given.
27///
28/// An **empty `relative_path` marks a member absent from the local library**
29/// (Liked from another creator, or filtered out by `--limit`/`--since`). Such an
30/// entry renders as a `# (not in library) <title>` comment line rather than an
31/// `#EXTINF` + path pair, so the playlist never carries a dangling path
32/// (HARDENING L1). A present member always has a non-empty relative path.
33#[derive(Debug, Clone, Copy)]
34pub struct M3u8Entry<'a> {
35    pub title: &'a str,
36    pub duration_secs: f64,
37    pub relative_path: &'a str,
38}
39
40/// Render an extended-M3U8 playlist named `name` from `entries`, preserving
41/// their order.
42///
43/// The output opens with the `#EXTM3U` header and a `#PLAYLIST:<name>` line,
44/// then per entry emits either an `#EXTINF:<seconds>,<title>` line followed by
45/// the relative path line (a member present in the library), or a
46/// `# (not in library) <title>` comment line (an [`M3u8Entry`] with an empty
47/// relative path — HARDENING L1). Seconds are rounded to the nearest whole
48/// number. Carriage returns and line feeds in the name, title, and path are
49/// folded to spaces so a single field can never break the line structure.
50pub fn render_m3u8(name: &str, entries: &[M3u8Entry<'_>]) -> String {
51    let mut out = String::from("#EXTM3U\n");
52    let _ = writeln!(out, "#PLAYLIST:{}", to_single_line(name));
53    for entry in entries {
54        let title = to_single_line(entry.title);
55        if entry.relative_path.is_empty() {
56            // L1: a member absent from the local library — a comment, never a
57            // dangling path line.
58            let _ = writeln!(out, "# (not in library) {title}");
59            continue;
60        }
61        let path = to_single_line(entry.relative_path);
62        let seconds = extinf_seconds(entry.duration_secs);
63        let _ = write!(out, "#EXTINF:{seconds},{title}\n{path}\n");
64    }
65    out
66}
67
68/// One clip's row in the library index.
69///
70/// The field set is stable and additive: add fields, never rename them. Genuinely
71/// unknown live-only fields are `null` (`Option::None`), never an empty string or
72/// `0`, so a consumer can tell "absent from this run's live feed" from "empty".
73#[derive(Debug, Serialize)]
74struct IndexEntry {
75    id: String,
76    path: String,
77    format: AudioFormat,
78    size: u64,
79    title: String,
80    artist: Option<String>,
81    handle: Option<String>,
82    album: String,
83    root_id: String,
84    created_at: Option<String>,
85    duration: Option<f64>,
86    tags: Option<String>,
87}
88
89/// The serialised shape of the whole-library index.
90#[derive(Debug, Serialize)]
91struct LibraryIndex {
92    schema_version: u32,
93    clips: Vec<IndexEntry>,
94}
95
96/// Render the whole-library index as a stable, pretty-printed JSON document.
97///
98/// One row per `manifest` entry, in clip-id order (the manifest is a `BTreeMap`,
99/// so the order is deterministic), and only clips whose file exists on disk are
100/// listed, so the index never advertises a missing file. Durable fields come
101/// from the manifest and the archived [`LineageStore`]; live-only fields (artist,
102/// handle, duration, tags) come from `live` when the clip was seen this run and
103/// are `null` otherwise. The `album` is the raw logical album title, which
104/// legitimately differs from the sanitised, truncated album segment inside
105/// `path`. The renderer takes no clock, so the output is fully deterministic.
106pub fn render_library_index(
107    manifest: &Manifest,
108    store: &LineageStore,
109    live: &HashMap<&str, &Clip>,
110) -> String {
111    let clips = manifest
112        .iter()
113        .map(|(id, entry)| {
114            let live_clip = live.get(id.as_str()).copied();
115            let title = live_clip
116                .map(|clip| clip.title.clone())
117                .filter(|title| !title.is_empty())
118                .or_else(|| {
119                    store
120                        .node(id)
121                        .map(|node| node.title.clone())
122                        .filter(|title| !title.is_empty())
123                })
124                .unwrap_or_else(|| "Untitled".to_owned());
125            let artist =
126                live_clip.map(|clip| non_empty(&clip.display_name).unwrap_or("Suno").to_owned());
127            let handle = live_clip.and_then(|clip| non_empty(&clip.handle).map(str::to_owned));
128            let album = match live_clip {
129                Some(clip) => store.context_for(clip).album(&clip.title),
130                None => store.album_for_id(id),
131            };
132            let root_id = store
133                .get_root(id)
134                .map(|cached| cached.root_id.clone())
135                .filter(|root| !root.is_empty())
136                .unwrap_or_else(|| id.clone());
137            let created_at = store
138                .node(id)
139                .map(|node| node.created_at.clone())
140                .filter(|created| !created.is_empty());
141            let duration = live_clip.map(|clip| clip.duration);
142            let tags = live_clip.map(|clip| clip.tags.clone());
143            IndexEntry {
144                id: id.clone(),
145                path: entry.path.clone(),
146                format: entry.format,
147                size: entry.size,
148                title,
149                artist,
150                handle,
151                album,
152                root_id,
153                created_at,
154                duration,
155                tags,
156            }
157        })
158        .collect();
159    let index = LibraryIndex {
160        schema_version: INDEX_SCHEMA_VERSION,
161        clips,
162    };
163    serde_json::to_string_pretty(&index).expect("library index serialises")
164}
165
166/// `Some(s)` when `s` is non-empty, else `None`.
167///
168/// Mirrors the tagger's own emptiness test so the index `artist` agrees with the
169/// embedded `ARTIST` tag, including the shared `"Suno"` fallback.
170fn non_empty(s: &str) -> Option<&str> {
171    (!s.is_empty()).then_some(s)
172}
173
174/// Round a duration in seconds to the nearest whole second for `#EXTINF`.
175///
176/// Non-finite inputs fold to `0` so the playlist line stays well-formed.
177fn extinf_seconds(duration_secs: f64) -> i64 {
178    if duration_secs.is_finite() {
179        duration_secs.round() as i64
180    } else {
181        0
182    }
183}
184/// Fold carriage returns and line feeds to spaces, keeping the value on one line
185/// so it cannot break the surrounding text format.
186fn to_single_line(text: &str) -> String {
187    text.replace('\r', "").replace('\n', " ")
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn m3u8_preserves_order_and_rounds_extinf() {
196        let entries = [
197            M3u8Entry {
198                title: "First",
199                duration_secs: 211.6,
200                relative_path: "Artist/Album/First.flac",
201            },
202            M3u8Entry {
203                title: "Second, Take",
204                duration_secs: 90.5,
205                relative_path: "Artist/Album/Second.flac",
206            },
207            M3u8Entry {
208                title: "Third\nLine",
209                duration_secs: 30.2,
210                relative_path: "Artist/Album/Third.flac",
211            },
212        ];
213
214        let rendered = render_m3u8("Road Trip", &entries);
215
216        let expected = "#EXTM3U\n\
217            #PLAYLIST:Road Trip\n\
218            #EXTINF:212,First\n\
219            Artist/Album/First.flac\n\
220            #EXTINF:91,Second, Take\n\
221            Artist/Album/Second.flac\n\
222            #EXTINF:30,Third Line\n\
223            Artist/Album/Third.flac\n";
224        assert_eq!(rendered, expected);
225    }
226
227    #[test]
228    fn m3u8_strips_newlines_but_keeps_commas() {
229        let entries = [M3u8Entry {
230            title: "Hello, World\r\nSecond, Line",
231            duration_secs: 12.0,
232            relative_path: "Artist/Track.flac",
233        }];
234
235        let rendered = render_m3u8("Mix", &entries);
236
237        assert_eq!(
238            rendered,
239            "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:12,Hello, World Second, Line\nArtist/Track.flac\n"
240        );
241        assert!(!rendered.contains('\r'));
242        // Header, playlist name, one EXTINF line, one path line, trailing newline.
243        assert_eq!(rendered.lines().count(), 4);
244    }
245
246    #[test]
247    fn m3u8_folds_newlines_in_the_playlist_name() {
248        let rendered = render_m3u8("Road\r\nTrip", &[]);
249        assert_eq!(rendered, "#EXTM3U\n#PLAYLIST:Road Trip\n");
250    }
251
252    #[test]
253    fn m3u8_empty_list_is_header_and_name_only() {
254        assert_eq!(render_m3u8("Empty", &[]), "#EXTM3U\n#PLAYLIST:Empty\n");
255    }
256
257    #[test]
258    fn m3u8_absent_member_renders_a_comment_not_a_path() {
259        // L1: an empty relative path means the member is not in the local
260        // library, so it is a comment line with no #EXTINF and no path.
261        let entries = [
262            M3u8Entry {
263                title: "In Library",
264                duration_secs: 60.0,
265                relative_path: "Artist/In.flac",
266            },
267            M3u8Entry {
268                title: "Missing, Song",
269                duration_secs: 42.0,
270                relative_path: "",
271            },
272            M3u8Entry {
273                title: "Also Present",
274                duration_secs: 30.0,
275                relative_path: "Artist/Also.flac",
276            },
277        ];
278
279        let rendered = render_m3u8("Liked Songs", &entries);
280
281        let expected = "#EXTM3U\n\
282            #PLAYLIST:Liked Songs\n\
283            #EXTINF:60,In Library\n\
284            Artist/In.flac\n\
285            # (not in library) Missing, Song\n\
286            #EXTINF:30,Also Present\n\
287            Artist/Also.flac\n";
288        assert_eq!(rendered, expected);
289        // The absent member never contributes a bare path line.
290        assert!(!rendered.contains("#EXTINF:42"));
291    }
292
293    #[test]
294    fn m3u8_non_finite_duration_is_zero() {
295        let entries = [M3u8Entry {
296            title: "Unknown",
297            duration_secs: f64::NAN,
298            relative_path: "Artist/Unknown.flac",
299        }];
300
301        assert_eq!(
302            render_m3u8("Odd", &entries),
303            "#EXTM3U\n#PLAYLIST:Odd\n#EXTINF:0,Unknown\nArtist/Unknown.flac\n"
304        );
305    }
306
307    use crate::lineage::{Resolution, ResolveStatus, RootInfo};
308    use crate::manifest::ManifestEntry;
309    use serde_json::Value;
310    use std::collections::HashMap as Map;
311
312    fn manifest_entry(path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
313        ManifestEntry {
314            path: path.to_owned(),
315            format,
316            size,
317            ..Default::default()
318        }
319    }
320
321    fn clip(id: &str, title: &str) -> Clip {
322        Clip {
323            id: id.to_owned(),
324            title: title.to_owned(),
325            display_name: "alice".to_owned(),
326            handle: "alice_handle".to_owned(),
327            tags: "ambient, cinematic".to_owned(),
328            duration: 211.0,
329            created_at: "2024-03-10T14:22:01Z".to_owned(),
330            ..Default::default()
331        }
332    }
333
334    /// A store where `child` is a cover rooted at `root` ("Original"), both nodes
335    /// archived with created-at dates.
336    fn lineage_store() -> LineageStore {
337        let child = Clip {
338            id: "child".to_owned(),
339            title: "Cover Take".to_owned(),
340            created_at: "2024-05-01T00:00:00Z".to_owned(),
341            clip_type: "gen".to_owned(),
342            task: "cover".to_owned(),
343            cover_clip_id: "root".to_owned(),
344            edited_clip_id: "root".to_owned(),
345            ..Default::default()
346        };
347        let root = Clip {
348            id: "root".to_owned(),
349            title: "Original".to_owned(),
350            created_at: "2024-04-01T00:00:00Z".to_owned(),
351            ..Default::default()
352        };
353        let mut roots = HashMap::new();
354        roots.insert(
355            "child".to_owned(),
356            RootInfo {
357                root_id: "root".to_owned(),
358                root_title: "Original".to_owned(),
359                status: ResolveStatus::Resolved,
360            },
361        );
362        roots.insert(
363            "root".to_owned(),
364            RootInfo {
365                root_id: "root".to_owned(),
366                root_title: "Original".to_owned(),
367                status: ResolveStatus::Resolved,
368            },
369        );
370        let resolution = Resolution {
371            roots,
372            gap_filled: Vec::new(),
373        };
374        let mut store = LineageStore::new();
375        store.update(&[child, root], &resolution, "2024-06-01T00:00:00Z");
376        store
377    }
378
379    fn parse(rendered: &str) -> Value {
380        serde_json::from_str(rendered).expect("index is valid JSON")
381    }
382
383    #[test]
384    fn index_empty_manifest_is_exact() {
385        let rendered = render_library_index(&Manifest::new(), &LineageStore::new(), &Map::new());
386        assert_eq!(rendered, "{\n  \"schema_version\": 1,\n  \"clips\": []\n}");
387    }
388
389    #[test]
390    fn index_schema_version_matches_constant() {
391        let value = parse(&render_library_index(
392            &Manifest::new(),
393            &LineageStore::new(),
394            &Map::new(),
395        ));
396        assert_eq!(value["schema_version"], INDEX_SCHEMA_VERSION);
397    }
398
399    #[test]
400    fn index_live_clip_uses_live_fields_and_canonical_album() {
401        let mut manifest = Manifest::new();
402        manifest.insert(
403            "child",
404            manifest_entry("Original/Cover Take.flac", AudioFormat::Flac, 99),
405        );
406        let store = lineage_store();
407        let clip = clip("child", "Cover Take");
408        let mut live: Map<&str, &Clip> = Map::new();
409        live.insert("child", &clip);
410
411        let value = parse(&render_library_index(&manifest, &store, &live));
412        let row = &value["clips"][0];
413        assert_eq!(row["id"], "child");
414        assert_eq!(row["path"], "Original/Cover Take.flac");
415        assert_eq!(row["format"], "flac");
416        assert_eq!(row["size"], 99);
417        assert_eq!(row["title"], "Cover Take");
418        assert_eq!(row["artist"], "alice");
419        assert_eq!(row["handle"], "alice_handle");
420        // Canonical album is the resolved root's title, matching context_for.
421        assert_eq!(row["album"], "Original");
422        assert_eq!(row["root_id"], "root");
423        assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
424        assert_eq!(row["duration"], 211.0);
425        assert_eq!(row["tags"], "ambient, cinematic");
426    }
427
428    #[test]
429    fn index_on_disk_clip_nulls_live_only_fields() {
430        let mut manifest = Manifest::new();
431        manifest.insert(
432            "child",
433            manifest_entry("Original/Cover Take.flac", AudioFormat::Mp3, 7),
434        );
435        let store = lineage_store();
436
437        // The clip is not in this run's live set.
438        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
439        let row = &value["clips"][0];
440        // Durable fields are still present, sourced from manifest and store.
441        assert_eq!(row["format"], "mp3");
442        assert_eq!(row["size"], 7);
443        assert_eq!(row["title"], "Cover Take");
444        assert_eq!(row["album"], "Original");
445        assert_eq!(row["root_id"], "root");
446        assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
447        // Live-only fields are null, never empty-string or zero.
448        assert!(row["artist"].is_null());
449        assert!(row["handle"].is_null());
450        assert!(row["duration"].is_null());
451        assert!(row["tags"].is_null());
452    }
453
454    #[test]
455    fn index_album_resolves_for_both_live_and_on_disk_clips() {
456        let mut manifest = Manifest::new();
457        manifest.insert(
458            "child",
459            manifest_entry("Original/a.flac", AudioFormat::Flac, 1),
460        );
461        let store = lineage_store();
462
463        let live_clip = clip("child", "Cover Take");
464        let mut live: Map<&str, &Clip> = Map::new();
465        live.insert("child", &live_clip);
466        let live_value = parse(&render_library_index(&manifest, &store, &live));
467        let on_disk_value = parse(&render_library_index(&manifest, &store, &Map::new()));
468
469        // Both paths derive the same canonical album via the shared rule.
470        assert_eq!(live_value["clips"][0]["album"], "Original");
471        assert_eq!(on_disk_value["clips"][0]["album"], "Original");
472    }
473
474    #[test]
475    fn index_album_differs_from_sanitised_path_segment() {
476        // The path album segment is sanitised and truncated; the index album is
477        // the raw logical title, so they legitimately differ. Here the root
478        // title carries a slash that naming would never leave in a folder name.
479        let mut manifest = Manifest::new();
480        manifest.insert(
481            "child",
482            manifest_entry("AC-DC Live/song.flac", AudioFormat::Flac, 1),
483        );
484        let raw_root = Clip {
485            id: "root".to_owned(),
486            title: "AC/DC: Live!".to_owned(),
487            created_at: "2024-04-01T00:00:00Z".to_owned(),
488            ..Default::default()
489        };
490        let child = Clip {
491            id: "child".to_owned(),
492            title: "song".to_owned(),
493            clip_type: "gen".to_owned(),
494            task: "cover".to_owned(),
495            cover_clip_id: "root".to_owned(),
496            edited_clip_id: "root".to_owned(),
497            ..Default::default()
498        };
499        let mut roots = HashMap::new();
500        roots.insert(
501            "child".to_owned(),
502            RootInfo {
503                root_id: "root".to_owned(),
504                root_title: "AC/DC: Live!".to_owned(),
505                status: ResolveStatus::Resolved,
506            },
507        );
508        let resolution = Resolution {
509            roots,
510            gap_filled: Vec::new(),
511        };
512        let mut store = LineageStore::new();
513        store.update(&[child, raw_root], &resolution, "2024-06-01T00:00:00Z");
514
515        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
516        let row = &value["clips"][0];
517        assert_eq!(row["album"], "AC/DC: Live!");
518        // The path segment is the sanitised, slash-free folder.
519        let album = row["album"].as_str().unwrap();
520        let path_segment = row["path"].as_str().unwrap().split('/').next().unwrap();
521        assert_ne!(album, path_segment);
522        assert_eq!(path_segment, "AC-DC Live");
523    }
524
525    #[test]
526    fn index_iterates_in_clip_id_order() {
527        let mut manifest = Manifest::new();
528        manifest.insert("c", manifest_entry("c.flac", AudioFormat::Flac, 1));
529        manifest.insert("a", manifest_entry("a.flac", AudioFormat::Flac, 1));
530        manifest.insert("b", manifest_entry("b.flac", AudioFormat::Flac, 1));
531
532        let value = parse(&render_library_index(
533            &manifest,
534            &LineageStore::new(),
535            &Map::new(),
536        ));
537        let ids: Vec<&str> = value["clips"]
538            .as_array()
539            .unwrap()
540            .iter()
541            .map(|row| row["id"].as_str().unwrap())
542            .collect();
543        assert_eq!(ids, ["a", "b", "c"]);
544    }
545
546    #[test]
547    fn index_unknown_clip_is_well_formed_with_defaults() {
548        // A manifest id absent from both live and the store nodes: self-root,
549        // "Untitled", null live fields, no panic.
550        let mut manifest = Manifest::new();
551        manifest.insert("orphan", manifest_entry("orphan.wav", AudioFormat::Wav, 3));
552
553        let value = parse(&render_library_index(
554            &manifest,
555            &LineageStore::new(),
556            &Map::new(),
557        ));
558        let row = &value["clips"][0];
559        assert_eq!(row["id"], "orphan");
560        assert_eq!(row["title"], "Untitled");
561        assert_eq!(row["format"], "wav");
562        assert_eq!(row["album"], "");
563        assert_eq!(row["root_id"], "orphan");
564        assert!(row["created_at"].is_null());
565        assert!(row["artist"].is_null());
566        assert!(row["tags"].is_null());
567    }
568
569    #[test]
570    fn index_title_falls_back_to_store_node_then_untitled() {
571        let mut manifest = Manifest::new();
572        manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
573        let store = lineage_store();
574        // No live clip, so title comes from the archived node.
575        let value = parse(&render_library_index(&manifest, &store, &Map::new()));
576        assert_eq!(value["clips"][0]["title"], "Cover Take");
577    }
578
579    #[test]
580    fn index_artist_falls_back_to_suno_when_display_name_empty() {
581        let mut manifest = Manifest::new();
582        manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
583        let mut anon = clip("child", "Cover Take");
584        anon.display_name = String::new();
585        let mut live: Map<&str, &Clip> = Map::new();
586        live.insert("child", &anon);
587        let value = parse(&render_library_index(
588            &manifest,
589            &LineageStore::new(),
590            &live,
591        ));
592        // Matches TrackMetadata::from_clip's "Suno" fallback for the ARTIST tag.
593        assert_eq!(value["clips"][0]["artist"], "Suno");
594    }
595
596    #[test]
597    fn index_unicode_round_trips() {
598        let mut manifest = Manifest::new();
599        manifest.insert("🎵", manifest_entry("音楽/曲.flac", AudioFormat::Flac, 5));
600        let unicode = clip("🎵", "音楽 \"quoted\"");
601        let mut live: Map<&str, &Clip> = Map::new();
602        live.insert("🎵", &unicode);
603
604        let rendered = render_library_index(&manifest, &LineageStore::new(), &live);
605        let value = parse(&rendered);
606        let row = &value["clips"][0];
607        assert_eq!(row["id"], "🎵");
608        assert_eq!(row["path"], "音楽/曲.flac");
609        assert_eq!(row["title"], "音楽 \"quoted\"");
610    }
611}