1use 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
17pub const INDEX_SCHEMA_VERSION: u32 = 1;
22
23#[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 {
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 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#[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#[derive(Debug, Serialize)]
91struct LibraryIndex {
92 schema_version: u32,
93 clips: Vec<IndexEntry>,
94}
95
96pub 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
166fn non_empty(s: &str) -> Option<&str> {
171 (!s.is_empty()).then_some(s)
172}
173
174fn extinf_seconds(duration_secs: f64) -> i64 {
178 if duration_secs.is_finite() {
179 duration_secs.round() as i64
180 } else {
181 0
182 }
183}
184fn 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 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 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 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 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 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 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
439 let row = &value["clips"][0];
440 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 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 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 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 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 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 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 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}