1use 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::lyrics::AlignedLyrics;
16use crate::manifest::Manifest;
17use crate::model::Clip;
18use crate::tag::{TrackMetadata, non_empty};
19use crate::vocab::AudioFormat;
20
21pub const INDEX_SCHEMA_VERSION: u32 = 1;
26
27#[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
40pub 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 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#[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#[derive(Debug, Serialize)]
87struct LibraryIndex {
88 schema_version: u32,
89 clips: Vec<IndexEntry>,
90}
91
92pub 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
160fn extinf_seconds(duration_secs: f64) -> i64 {
164 if duration_secs.is_finite() {
165 duration_secs.round() as i64
166 } else {
167 0
168 }
169}
170fn to_single_line(text: &str) -> String {
173 text.replace('\r', "").replace('\n', " ")
174}
175
176pub fn render_clip_details(clip: &Clip, lineage: &LineageContext) -> String {
186 let meta = TrackMetadata::from_clip(clip, lineage);
187 let url = if clip.id.is_empty() {
188 String::new()
189 } else {
190 format!("{SUNO_SONG_BASE_URL}/{}", clip.id)
191 };
192 let fields: [(&str, &str); 17] = [
193 ("Title", &meta.title),
194 ("Artist", &meta.artist),
195 ("Album", &meta.album),
196 ("Album Artist", &meta.album_artist),
197 ("Date", &meta.date),
198 ("Duration", &format_duration(clip.duration)),
199 ("Model", &meta.model),
200 ("Handle", &meta.handle),
201 ("Style", &meta.style),
202 ("Style Summary", &meta.style_summary),
203 ("Comment", &meta.comment),
204 ("Prompt", &clip.prompt),
205 ("Parent", &meta.parent),
206 ("Root", &meta.root),
207 ("Lineage", &meta.lineage),
208 ("Id", &clip.id),
209 ("Url", &url),
210 ];
211 let mut out = String::new();
212 for (label, value) in fields {
213 if value.is_empty() {
214 continue;
215 }
216 let _ = writeln!(out, "{label}: {}", to_single_line(value));
217 }
218 out
219}
220
221pub fn render_clip_lyrics(clip: &Clip) -> Option<String> {
227 if clip.lyrics.trim().is_empty() {
228 return None;
229 }
230 Some(format!("{}\n", clip.lyrics.trim_end()))
231}
232
233pub fn render_clip_lrc(clip: &Clip, lineage: &LineageContext) -> Option<String> {
240 if clip.lyrics.trim().is_empty() {
241 return None;
242 }
243 let mut out = lrc_headers(clip, lineage);
244 for line in clip.lyrics.trim_end().lines() {
245 let _ = writeln!(out, "{line}");
246 }
247 Some(out)
248}
249
250pub fn render_synced_lrc(
258 clip: &Clip,
259 lineage: &LineageContext,
260 aligned: &AlignedLyrics,
261) -> Option<String> {
262 let body = aligned.lrc_body();
263 if body.is_empty() {
264 return None;
265 }
266 let mut out = lrc_headers(clip, lineage);
267 out.push_str(&body);
268 Some(out)
269}
270
271fn lrc_headers(clip: &Clip, lineage: &LineageContext) -> String {
274 let meta = TrackMetadata::from_clip(clip, lineage);
275 let length = format_duration(clip.duration);
276 let headers: [(&str, &str); 5] = [
277 ("ti", &meta.title),
278 ("ar", &meta.artist),
279 ("al", &meta.album),
280 ("length", &length),
281 ("re", "rs-suno"),
282 ];
283 let mut out = String::new();
284 for (tag, value) in headers {
285 if value.is_empty() {
286 continue;
287 }
288 let _ = writeln!(out, "[{tag}:{}]", to_single_line(value));
289 }
290 out
291}
292
293fn format_duration(secs: f64) -> String {
296 if !secs.is_finite() || secs <= 0.0 {
297 return String::new();
298 }
299 let total = secs.round() as i64;
300 format!("{}:{:02}", total / 60, total % 60)
301}
302
303#[cfg(test)]
304mod tests;