use std::collections::HashMap;
use std::fmt::Write as _;
use serde::Serialize;
use crate::consts::SUNO_SONG_BASE_URL;
use crate::graph::LineageStore;
use crate::lineage::LineageContext;
use crate::manifest::Manifest;
use crate::model::Clip;
use crate::tag::{TrackMetadata, non_empty};
use crate::textfmt::{format_duration, to_single_line};
use crate::vocab::AudioFormat;
pub const INDEX_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy)]
pub struct M3u8Entry<'a> {
pub title: &'a str,
pub duration_secs: f64,
pub relative_path: &'a str,
}
pub fn render_m3u8(name: &str, entries: &[M3u8Entry<'_>]) -> String {
let mut out = String::from("#EXTM3U\n");
let _ = writeln!(out, "#PLAYLIST:{}", to_single_line(name));
for entry in entries {
let title = to_single_line(entry.title);
if entry.relative_path.is_empty() {
let _ = writeln!(out, "# (not in library) {title}");
continue;
}
let path = to_single_line(entry.relative_path);
let seconds = extinf_seconds(entry.duration_secs);
let _ = write!(out, "#EXTINF:{seconds},{title}\n{path}\n");
}
out
}
#[derive(Debug, Serialize)]
struct IndexEntry {
id: String,
path: String,
format: AudioFormat,
size: u64,
title: String,
artist: Option<String>,
handle: Option<String>,
album: String,
root_id: String,
created_at: Option<String>,
duration: Option<f64>,
tags: Option<String>,
}
#[derive(Debug, Serialize)]
struct LibraryIndex {
schema_version: u32,
clips: Vec<IndexEntry>,
}
pub fn render_library_index(
manifest: &Manifest,
store: &LineageStore,
live: &HashMap<&str, &Clip>,
) -> String {
let clips = manifest
.iter()
.map(|(id, entry)| {
let live_clip = live.get(id.as_str()).copied();
let title = live_clip
.map(|clip| clip.title.clone())
.filter(|title| !title.is_empty())
.or_else(|| {
store
.node(id)
.map(|node| node.title.clone())
.filter(|title| !title.is_empty())
})
.unwrap_or_else(|| "Untitled".to_owned());
let artist =
live_clip.map(|clip| non_empty(&clip.display_name).unwrap_or("Suno").to_owned());
let handle = live_clip.and_then(|clip| non_empty(&clip.handle).map(str::to_owned));
let album = match live_clip {
Some(clip) => store.context_for(clip).album(&clip.title),
None => store.album_for_id(id),
};
let root_id = store
.get_root(id)
.map(|cached| cached.root_id.clone())
.filter(|root| !root.is_empty())
.unwrap_or_else(|| id.clone());
let created_at = store
.node(id)
.map(|node| node.created_at.clone())
.filter(|created| !created.is_empty());
let duration = live_clip.map(|clip| clip.duration);
let tags = live_clip.map(|clip| clip.tags.clone());
IndexEntry {
id: id.clone(),
path: entry.path.clone(),
format: entry.format,
size: entry.size,
title,
artist,
handle,
album,
root_id,
created_at,
duration,
tags,
}
})
.collect();
let index = LibraryIndex {
schema_version: INDEX_SCHEMA_VERSION,
clips,
};
serde_json::to_string_pretty(&index).expect("library index serialises")
}
fn extinf_seconds(duration_secs: f64) -> i64 {
if duration_secs.is_finite() {
duration_secs.round() as i64
} else {
0
}
}
pub fn render_clip_details(clip: &Clip, lineage: &LineageContext) -> String {
let meta = TrackMetadata::from_clip(clip, lineage);
let url = if clip.id.is_empty() {
String::new()
} else {
format!("{SUNO_SONG_BASE_URL}/{}", clip.id)
};
let fields: [(&str, &str); 17] = [
("Title", &meta.title),
("Artist", &meta.artist),
("Album", &meta.album),
("Album Artist", &meta.album_artist),
("Date", &meta.date),
("Duration", &format_duration(clip.duration)),
("Model", &meta.model),
("Handle", &meta.handle),
("Style", &meta.style),
("Style Summary", &meta.style_summary),
("Comment", &meta.comment),
("Prompt", &clip.prompt),
("Parent", &meta.parent),
("Root", &meta.root),
("Lineage", &meta.lineage),
("Id", &clip.id),
("Url", &url),
];
let mut out = String::new();
for (label, value) in fields {
if value.is_empty() {
continue;
}
let _ = writeln!(out, "{label}: {}", to_single_line(value));
}
out
}
#[cfg(test)]
mod tests;