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 is pure: clip data and relative paths in, the text the CLI
5//! writes to disk out, with no IO, clock, or network.
6
7use std::collections::HashMap;
8use std::fmt::Write as _;
9
10use serde::Serialize;
11
12use crate::consts::SUNO_SONG_BASE_URL;
13use crate::graph::LineageStore;
14use crate::lineage::LineageContext;
15use crate::manifest::Manifest;
16use crate::model::Clip;
17use crate::tag::{TrackMetadata, non_empty};
18use crate::textfmt::{format_duration, to_single_line};
19use crate::vocab::AudioFormat;
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. Order is significant and
28/// preserved exactly as given.
29///
30/// An empty `relative_path` marks a member absent from the local library, which
31/// renders as a `# (not in library) <title>` comment rather than an `#EXTINF` +
32/// path pair, so the playlist never carries a dangling path (HARDENING L1).
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/// Opens with `#EXTM3U` and `#PLAYLIST:<name>`, then per entry emits either an
44/// `#EXTINF:<seconds>,<title>` line plus the path, or a `# (not in library)`
45/// comment for an empty relative path (HARDENING L1). CR/LF in any field is
46/// folded to spaces so one field can never break the line structure.
47pub fn render_m3u8(name: &str, entries: &[M3u8Entry<'_>]) -> String {
48    let mut out = String::from("#EXTM3U\n");
49    let _ = writeln!(out, "#PLAYLIST:{}", to_single_line(name));
50    for entry in entries {
51        let title = to_single_line(entry.title);
52        if entry.relative_path.is_empty() {
53            // L1: an absent member renders as a comment, never a dangling path.
54            let _ = writeln!(out, "# (not in library) {title}");
55            continue;
56        }
57        let path = to_single_line(entry.relative_path);
58        let seconds = extinf_seconds(entry.duration_secs);
59        let _ = write!(out, "#EXTINF:{seconds},{title}\n{path}\n");
60    }
61    out
62}
63
64/// One clip's row in the library index.
65///
66/// The field set is stable and additive: add fields, never rename them. Genuinely
67/// unknown live-only fields are `null` (`Option::None`), never an empty string or
68/// `0`, so a consumer can tell "absent from this run's live feed" from "empty".
69#[derive(Debug, Serialize)]
70struct IndexEntry {
71    id: String,
72    path: String,
73    format: AudioFormat,
74    size: u64,
75    title: String,
76    artist: Option<String>,
77    handle: Option<String>,
78    album: String,
79    root_id: String,
80    created_at: Option<String>,
81    duration: Option<f64>,
82    tags: Option<String>,
83}
84
85/// The serialised shape of the whole-library index.
86#[derive(Debug, Serialize)]
87struct LibraryIndex {
88    schema_version: u32,
89    clips: Vec<IndexEntry>,
90}
91
92/// Render the whole-library index as a stable, pretty-printed JSON document.
93///
94/// One row per `manifest` entry in clip-id order, listing only clips whose file
95/// exists on disk so the index never advertises a missing file. Durable fields
96/// come from the manifest and the archived [`LineageStore`]; live-only fields
97/// (artist, handle, duration, tags) come from `live` when the clip was seen this
98/// run and are `null` otherwise. `album` is the raw logical title, which
99/// legitimately differs from the sanitised segment inside `path`.
100pub fn render_library_index(
101    manifest: &Manifest,
102    store: &LineageStore,
103    live: &HashMap<&str, &Clip>,
104) -> String {
105    let clips = manifest
106        .iter()
107        .map(|(id, entry)| {
108            let live_clip = live.get(id.as_str()).copied();
109            let title = live_clip
110                .map(|clip| clip.title.clone())
111                .filter(|title| !title.is_empty())
112                .or_else(|| {
113                    store
114                        .node(id)
115                        .map(|node| node.title.clone())
116                        .filter(|title| !title.is_empty())
117                })
118                .unwrap_or_else(|| "Untitled".to_owned());
119            let artist =
120                live_clip.map(|clip| non_empty(&clip.display_name).unwrap_or("Suno").to_owned());
121            let handle = live_clip.and_then(|clip| non_empty(&clip.handle).map(str::to_owned));
122            let album = match live_clip {
123                Some(clip) => store.context_for(clip).album(&clip.title),
124                None => store.album_for_id(id),
125            };
126            let root_id = store
127                .get_root(id)
128                .map(|cached| cached.root_id.clone())
129                .filter(|root| !root.is_empty())
130                .unwrap_or_else(|| id.clone());
131            let created_at = store
132                .node(id)
133                .map(|node| node.created_at.clone())
134                .filter(|created| !created.is_empty());
135            let duration = live_clip.map(|clip| clip.duration);
136            let tags = live_clip.map(|clip| clip.tags.clone());
137            IndexEntry {
138                id: id.clone(),
139                path: entry.path.clone(),
140                format: entry.format,
141                size: entry.size,
142                title,
143                artist,
144                handle,
145                album,
146                root_id,
147                created_at,
148                duration,
149                tags,
150            }
151        })
152        .collect();
153    let index = LibraryIndex {
154        schema_version: INDEX_SCHEMA_VERSION,
155        clips,
156    };
157    serde_json::to_string_pretty(&index).expect("library index serialises")
158}
159
160/// Round a duration in seconds to the nearest whole second for `#EXTINF`.
161///
162/// Non-finite inputs fold to `0` so the playlist line stays well-formed.
163fn extinf_seconds(duration_secs: f64) -> i64 {
164    if duration_secs.is_finite() {
165        duration_secs.round() as i64
166    } else {
167        0
168    }
169}
170/// Render the plain-text per-song details sidecar for `clip`.
171///
172/// A fixed-order block of `Label: value` lines from the same [`TrackMetadata`]
173/// that drives the embedded tags, plus the clip id, `mm:ss` duration, and the
174/// canonical `https://suno.com/song/<id>` page URL. Empty fields are omitted.
175/// The generation prompt is labelled `Prompt:`, never `Lyrics:`.
176/// [`TrackMetadata`] carries no URLs, so signed CDN links are excluded
177/// automatically. Every value is folded to one line so a field can never break
178/// the block.
179pub fn render_clip_details(clip: &Clip, lineage: &LineageContext) -> String {
180    let meta = TrackMetadata::from_clip(clip, lineage);
181    let url = if clip.id.is_empty() {
182        String::new()
183    } else {
184        format!("{SUNO_SONG_BASE_URL}/{}", clip.id)
185    };
186    let fields: [(&str, &str); 17] = [
187        ("Title", &meta.title),
188        ("Artist", &meta.artist),
189        ("Album", &meta.album),
190        ("Album Artist", &meta.album_artist),
191        ("Date", &meta.date),
192        ("Duration", &format_duration(clip.duration)),
193        ("Model", &meta.model),
194        ("Handle", &meta.handle),
195        ("Style", &meta.style),
196        ("Style Summary", &meta.style_summary),
197        ("Comment", &meta.comment),
198        ("Prompt", &clip.prompt),
199        ("Parent", &meta.parent),
200        ("Root", &meta.root),
201        ("Lineage", &meta.lineage),
202        ("Id", &clip.id),
203        ("Url", &url),
204    ];
205    let mut out = String::new();
206    for (label, value) in fields {
207        if value.is_empty() {
208            continue;
209        }
210        let _ = writeln!(out, "{label}: {}", to_single_line(value));
211    }
212    out
213}
214
215#[cfg(test)]
216mod tests;