forked from Keelrleek/SoulStream
1198 lines
46 KiB
Rust
1198 lines
46 KiB
Rust
//! Playlist file parsing and writing — pure functions, no Tauri, no database,
|
||
//! no network. Every parser reads user-selected but still untrusted bytes, so
|
||
//! everything here is bounded: source size, entry count, field length. A
|
||
//! parser returns entries and warnings; it never touches the filesystem, and
|
||
//! resolving a locator against the library is the command layer's job.
|
||
//!
|
||
//! Formats, per the playlist feature research (TODO-Feature-Docs/
|
||
//! playlist-management-and-setlist-import): M3U/M3U8 (Extended M3U, HLS
|
||
//! rejected), PLS, XSPF v1, a versioned SoulStream CSV profile, a versioned
|
||
//! SoulStream JSON profile, and pasted text tracklists with optional setlist
|
||
//! timestamps.
|
||
|
||
use std::fmt::Write as _;
|
||
|
||
/// Source files past this size are refused outright — the largest legitimate
|
||
/// playlist is a few hundred KB; an 8 MB "playlist" is a mistake or an attack.
|
||
pub const MAX_SOURCE_BYTES: usize = 8 * 1024 * 1024;
|
||
/// Entries beyond this are dropped with a warning.
|
||
pub const MAX_ENTRIES: usize = 10_000;
|
||
/// Any single field (title, artist, locator…) is clipped to this many chars.
|
||
pub const MAX_FIELD_CHARS: usize = 1024;
|
||
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||
pub struct ParsedPlaylist {
|
||
/// "m3u" | "pls" | "xspf" | "csv" | "soulstream" | "text"
|
||
pub format: String,
|
||
pub title: Option<String>,
|
||
pub description: Option<String>,
|
||
pub entries: Vec<ParsedEntry>,
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
/// One ordered occurrence as the source file stated it. `location` is the raw
|
||
/// locator exactly as decoded (relative paths not yet resolved); `raw` is the
|
||
/// sanitized provenance line kept for repair tools.
|
||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||
pub struct ParsedEntry {
|
||
pub ordinal: u32,
|
||
pub title: Option<String>,
|
||
pub artist: Option<String>,
|
||
pub album: Option<String>,
|
||
pub duration_secs: Option<u32>,
|
||
pub location: Option<String>,
|
||
/// Setlist timestamp (where in a continuous source this entry starts).
|
||
pub start_ms: Option<i64>,
|
||
pub raw: String,
|
||
}
|
||
|
||
// ---- decoding --------------------------------------------------------------
|
||
|
||
/// Decode playlist bytes: BOM-tolerant strict UTF-8 first, then a Windows-1252
|
||
/// reading with a warning. Never silently replaces undecodable bytes — the
|
||
/// 1252 mapping is total, so the fallback is deterministic and reversible.
|
||
fn decode(bytes: &[u8], warnings: &mut Vec<String>) -> String {
|
||
// UTF-16 with a BOM (Windows Notepad's old default "Unicode") decodes
|
||
// properly instead of arriving as cp1252 mojibake.
|
||
if bytes.len() >= 2 && (bytes[..2] == [0xFF, 0xFE] || bytes[..2] == [0xFE, 0xFF]) {
|
||
let le = bytes[0] == 0xFF;
|
||
let units: Vec<u16> = bytes[2..]
|
||
.chunks_exact(2)
|
||
.map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) })
|
||
.collect();
|
||
return String::from_utf16_lossy(&units);
|
||
}
|
||
let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
|
||
match std::str::from_utf8(bytes) {
|
||
Ok(s) => s.to_string(),
|
||
Err(_) => {
|
||
warnings.push("Not valid UTF-8 — read with the Windows-1252 legacy encoding".into());
|
||
bytes.iter().map(|&b| cp1252_char(b)).collect()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Windows-1252: identical to Latin-1 except the 0x80–0x9F block.
|
||
fn cp1252_char(b: u8) -> char {
|
||
const HIGH: [char; 32] = [
|
||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž',
|
||
'\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9d}',
|
||
'ž', 'Ÿ',
|
||
];
|
||
match b {
|
||
0x80..=0x9F => HIGH[(b - 0x80) as usize],
|
||
other => other as char,
|
||
}
|
||
}
|
||
|
||
fn clip(s: &str) -> String {
|
||
let s = s.trim();
|
||
if s.chars().count() > MAX_FIELD_CHARS {
|
||
s.chars().take(MAX_FIELD_CHARS).collect()
|
||
} else {
|
||
s.to_string()
|
||
}
|
||
}
|
||
|
||
fn non_empty(s: &str) -> Option<String> {
|
||
let t = clip(s);
|
||
(!t.is_empty()).then_some(t)
|
||
}
|
||
|
||
// ---- format detection ------------------------------------------------------
|
||
|
||
/// Decide the parser from the filename extension first, content second. A
|
||
/// `.m3u8` holding HLS tags is refused later by the M3U parser itself.
|
||
pub fn detect_format(name: &str, bytes: &[u8]) -> &'static str {
|
||
let ext = name
|
||
.rsplit('.')
|
||
.next()
|
||
.unwrap_or_default()
|
||
.to_ascii_lowercase();
|
||
match ext.as_str() {
|
||
"m3u" | "m3u8" => return "m3u",
|
||
"pls" => return "pls",
|
||
"xspf" => return "xspf",
|
||
"csv" => return "csv",
|
||
"json" => return "soulstream",
|
||
"txt" => {} // fall through to sniffing: a .txt can hold pasted M3U
|
||
_ => {}
|
||
}
|
||
let mut warnings = Vec::new();
|
||
let head: String = decode(&bytes[..bytes.len().min(4096)], &mut warnings)
|
||
.trim_start()
|
||
.chars()
|
||
.take(512)
|
||
.collect();
|
||
if head.starts_with("#EXTM3U") {
|
||
"m3u"
|
||
} else if head.to_ascii_lowercase().starts_with("[playlist]") {
|
||
"pls"
|
||
} else if head.starts_with("<?xml") || head.contains("<playlist") {
|
||
"xspf"
|
||
} else if head.starts_with('{') {
|
||
"soulstream"
|
||
} else {
|
||
"text"
|
||
}
|
||
}
|
||
|
||
/// Parse any supported playlist source. `name` is the display filename used
|
||
/// for format detection and the default title.
|
||
pub fn parse(name: &str, bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
||
if bytes.len() > MAX_SOURCE_BYTES {
|
||
return Err(format!(
|
||
"{name} is {} MB — too large for a playlist file",
|
||
bytes.len() / (1024 * 1024)
|
||
));
|
||
}
|
||
let format = detect_format(name, bytes);
|
||
let mut parsed = match format {
|
||
"m3u" => parse_m3u(bytes)?,
|
||
"pls" => parse_pls(bytes)?,
|
||
"xspf" => parse_xspf(bytes)?,
|
||
"csv" => parse_csv(bytes)?,
|
||
"soulstream" => parse_soulstream_json(bytes)?,
|
||
_ => parse_text(bytes)?,
|
||
};
|
||
if parsed.title.is_none() {
|
||
let stem = name
|
||
.rsplit(['\\', '/'])
|
||
.next()
|
||
.unwrap_or(name)
|
||
.rsplit_once('.')
|
||
.map(|(s, _)| s)
|
||
.unwrap_or(name);
|
||
parsed.title = non_empty(stem);
|
||
}
|
||
Ok(parsed)
|
||
}
|
||
|
||
// ---- M3U / Extended M3U ----------------------------------------------------
|
||
|
||
fn parse_m3u(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
||
let mut warnings = Vec::new();
|
||
let text = decode(bytes, &mut warnings);
|
||
// HLS shares the extension and the header; its media/master tags mean
|
||
// this is a streaming manifest, not a list of songs.
|
||
if text.lines().any(|l| l.trim_start().starts_with("#EXT-X-")) {
|
||
return Err("This is an HLS streaming manifest, not a playlist of tracks".into());
|
||
}
|
||
let mut entries = Vec::new();
|
||
let mut pending: Option<(Option<u32>, Option<String>, Option<String>)> = None; // (dur, artist, title)
|
||
let mut title = None;
|
||
for line in text.lines() {
|
||
let line = line.trim_end_matches('\r').trim();
|
||
if line.is_empty() {
|
||
continue;
|
||
}
|
||
if let Some(rest) = line.strip_prefix("#EXTINF:") {
|
||
// "#EXTINF:245,Artist - Title" — duration may be float or -1.
|
||
let (dur, display) = rest.split_once(',').unwrap_or((rest, ""));
|
||
let duration = dur
|
||
.trim()
|
||
.parse::<f64>()
|
||
.ok()
|
||
.filter(|d| *d > 0.0)
|
||
.map(|d| d.round() as u32);
|
||
let (artist, dtitle) = split_artist_title(display);
|
||
pending = Some((duration, artist, dtitle));
|
||
continue;
|
||
}
|
||
if let Some(rest) = line.strip_prefix("#PLAYLIST:") {
|
||
title = non_empty(rest);
|
||
continue;
|
||
}
|
||
if line.starts_with('#') {
|
||
continue; // unknown directive — ignored for playback, harmless
|
||
}
|
||
if entries.len() >= MAX_ENTRIES {
|
||
warnings.push(format!("Stopped after {MAX_ENTRIES} entries"));
|
||
break;
|
||
}
|
||
let (duration_secs, artist, entry_title) = pending.take().unwrap_or((None, None, None));
|
||
entries.push(ParsedEntry {
|
||
ordinal: entries.len() as u32,
|
||
title: entry_title.or_else(|| file_stem_title(line)),
|
||
artist,
|
||
album: None,
|
||
duration_secs,
|
||
location: non_empty(line),
|
||
start_ms: None,
|
||
raw: clip(line),
|
||
});
|
||
}
|
||
Ok(ParsedPlaylist {
|
||
format: "m3u".into(),
|
||
title,
|
||
description: None,
|
||
entries,
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
/// "Artist - Title" (or the em/en-dash forms) split. Returns (artist, title);
|
||
/// no separator means the whole display string is the title.
|
||
pub(crate) fn split_artist_title(display: &str) -> (Option<String>, Option<String>) {
|
||
let display = display.trim();
|
||
if display.is_empty() {
|
||
return (None, None);
|
||
}
|
||
for sep in [" — ", " – ", " - "] {
|
||
if let Some((a, t)) = display.split_once(sep) {
|
||
return (non_empty(a), non_empty(t));
|
||
}
|
||
}
|
||
(None, non_empty(display))
|
||
}
|
||
|
||
/// A filename's stem as a last-resort display title, with a leading track
|
||
/// number stripped: `03 - Subterranean.flac` → `Subterranean`.
|
||
fn file_stem_title(locator: &str) -> Option<String> {
|
||
let file = locator.rsplit(['\\', '/']).next()?;
|
||
let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file);
|
||
let stem = stem.trim();
|
||
let digits: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||
let rest = stem[digits.len()..].trim_start_matches(['-', '.', ' ', '_']);
|
||
let cleaned = if !digits.is_empty() && digits.len() <= 3 && !rest.is_empty() {
|
||
rest
|
||
} else {
|
||
stem
|
||
};
|
||
non_empty(cleaned)
|
||
}
|
||
|
||
// ---- PLS -------------------------------------------------------------------
|
||
|
||
fn parse_pls(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
||
let mut warnings = Vec::new();
|
||
let text = decode(bytes, &mut warnings);
|
||
// Indexed keys arrive in any order; collect by index, emit by index order.
|
||
let mut by_index: std::collections::BTreeMap<u32, ParsedEntry> = Default::default();
|
||
for line in text.lines() {
|
||
let line = line.trim();
|
||
if line.is_empty() || line.starts_with('[') || line.starts_with(';') {
|
||
continue;
|
||
}
|
||
let Some((key, value)) = line.split_once('=') else { continue };
|
||
let key = key.trim();
|
||
let value = value.trim();
|
||
let (field, index) = match key.to_ascii_lowercase() {
|
||
k if k.starts_with("file") => ("file", k[4..].parse::<u32>().ok()),
|
||
k if k.starts_with("title") => ("title", k[5..].parse::<u32>().ok()),
|
||
k if k.starts_with("length") => ("length", k[6..].parse::<u32>().ok()),
|
||
_ => continue,
|
||
};
|
||
let Some(index) = index else { continue };
|
||
if index == 0 || index as usize > MAX_ENTRIES {
|
||
warnings.push(format!("Ignored out-of-range PLS index {index}"));
|
||
continue;
|
||
}
|
||
let entry = by_index.entry(index).or_default();
|
||
match field {
|
||
"file" => {
|
||
entry.location = non_empty(value);
|
||
entry.raw = clip(value);
|
||
if entry.title.is_none() {
|
||
entry.title = file_stem_title(value);
|
||
}
|
||
}
|
||
"title" => {
|
||
let (artist, title) = split_artist_title(value);
|
||
if artist.is_some() {
|
||
entry.artist = artist;
|
||
}
|
||
if title.is_some() {
|
||
entry.title = title;
|
||
}
|
||
}
|
||
"length" => {
|
||
entry.duration_secs = value.parse::<i64>().ok().filter(|v| *v > 0).map(|v| v as u32);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let entries: Vec<ParsedEntry> = by_index
|
||
.into_values()
|
||
.filter(|e| e.location.is_some() || e.title.is_some())
|
||
.enumerate()
|
||
.map(|(ordinal, mut e)| {
|
||
e.ordinal = ordinal as u32;
|
||
e
|
||
})
|
||
.collect();
|
||
Ok(ParsedPlaylist {
|
||
format: "pls".into(),
|
||
title: None,
|
||
description: None,
|
||
entries,
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
// ---- XSPF v1 ---------------------------------------------------------------
|
||
|
||
fn parse_xspf(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
||
use quick_xml::events::Event;
|
||
let mut warnings = Vec::new();
|
||
let text = decode(bytes, &mut warnings);
|
||
if text.contains("<!DOCTYPE") || text.contains("<!ENTITY") {
|
||
// quick-xml never resolves DTDs or external entities, but a document
|
||
// that carries them is not a plain playlist — say so instead of
|
||
// producing something subtly wrong.
|
||
return Err("This XSPF file contains a DTD/entity declaration and was refused".into());
|
||
}
|
||
let mut reader = quick_xml::Reader::from_str(&text);
|
||
reader.config_mut().trim_text(true);
|
||
let mut entries: Vec<ParsedEntry> = Vec::new();
|
||
let mut playlist_title = None;
|
||
let mut description = None;
|
||
let mut in_track = false;
|
||
let mut depth = 0usize;
|
||
let mut current = ParsedEntry::default();
|
||
// Which simple element we are inside (title/creator/album/location/duration/annotation).
|
||
let mut field: Option<&'static str> = None;
|
||
loop {
|
||
match reader.read_event() {
|
||
Ok(Event::Start(e)) => {
|
||
depth += 1;
|
||
if depth > 32 {
|
||
return Err("XSPF nesting too deep".into());
|
||
}
|
||
match e.local_name().as_ref() {
|
||
b"track" => {
|
||
in_track = true;
|
||
current = ParsedEntry::default();
|
||
}
|
||
b"title" => field = Some("title"),
|
||
b"creator" => field = Some("creator"),
|
||
b"album" => field = Some("album"),
|
||
b"location" => field = Some("location"),
|
||
b"duration" => field = Some("duration"),
|
||
b"annotation" => field = Some("annotation"),
|
||
_ => field = None,
|
||
}
|
||
}
|
||
Ok(Event::CData(t)) => {
|
||
// Some exporters wrap titles/locations in CDATA; the content
|
||
// is the same, just unescaped by definition.
|
||
let value = String::from_utf8_lossy(t.as_ref()).into_owned();
|
||
apply_xspf_field(in_track, field, &value, &mut current, &mut playlist_title, &mut description);
|
||
}
|
||
Ok(Event::Text(t)) => {
|
||
let value = t.unescape().map_err(|e| e.to_string())?.into_owned();
|
||
apply_xspf_field(in_track, field, &value, &mut current, &mut playlist_title, &mut description);
|
||
}
|
||
Ok(Event::End(e)) => {
|
||
depth = depth.saturating_sub(1);
|
||
field = None;
|
||
if e.local_name().as_ref() == b"track" && in_track {
|
||
in_track = false;
|
||
if entries.len() < MAX_ENTRIES {
|
||
let mut entry = std::mem::take(&mut current);
|
||
if entry.raw.is_empty() {
|
||
entry.raw = entry.title.clone().unwrap_or_default();
|
||
}
|
||
entry.ordinal = entries.len() as u32;
|
||
entries.push(entry);
|
||
} else {
|
||
warnings.push(format!("Stopped after {MAX_ENTRIES} entries"));
|
||
}
|
||
}
|
||
}
|
||
Ok(Event::Eof) => break,
|
||
Ok(_) => {}
|
||
Err(e) => return Err(format!("XSPF parse error: {e}")),
|
||
}
|
||
}
|
||
Ok(ParsedPlaylist {
|
||
format: "xspf".into(),
|
||
title: playlist_title,
|
||
description,
|
||
entries,
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
/// One XSPF simple-element value applied to the right slot — shared by the
|
||
/// Text and CDATA arms so the two encodings cannot drift apart.
|
||
fn apply_xspf_field(
|
||
in_track: bool,
|
||
field: Option<&'static str>,
|
||
value: &str,
|
||
current: &mut ParsedEntry,
|
||
playlist_title: &mut Option<String>,
|
||
description: &mut Option<String>,
|
||
) {
|
||
match (in_track, field) {
|
||
(true, Some("title")) => current.title = non_empty(value),
|
||
(true, Some("creator")) => current.artist = non_empty(value),
|
||
(true, Some("album")) => current.album = non_empty(value),
|
||
(true, Some("location")) => {
|
||
current.location = non_empty(&decode_file_uri(value));
|
||
current.raw = clip(value);
|
||
}
|
||
(true, Some("duration")) => {
|
||
// XSPF duration is milliseconds.
|
||
current.duration_secs = value
|
||
.trim()
|
||
.parse::<u64>()
|
||
.ok()
|
||
.map(|ms| (ms / 1000) as u32)
|
||
.filter(|s| *s > 0);
|
||
}
|
||
(false, Some("title")) => *playlist_title = non_empty(value),
|
||
(false, Some("annotation")) => *description = non_empty(value),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// `file:///C:/Music/x.flac` → `C:\Music\x.flac`; percent-decoding included.
|
||
/// A schemeless RELATIVE reference is also a URI, so its percent-escapes
|
||
/// decode too (`My%20Song.flac` names "My Song.flac"). Other schemes pass
|
||
/// through untouched (they stay unresolved locators).
|
||
fn decode_file_uri(value: &str) -> String {
|
||
let Some(rest) = value.strip_prefix("file://") else {
|
||
// No scheme at all → a relative URI reference: percent-decode it so
|
||
// resolution against the playlist directory sees the real filename.
|
||
if !value.contains("://") && value.contains('%') {
|
||
return percent_decode(value);
|
||
}
|
||
return value.to_string();
|
||
};
|
||
let rest = rest.strip_prefix('/').unwrap_or(rest);
|
||
let decoded = percent_decode(rest);
|
||
// A Windows drive path ("C:/…") wants backslashes; a UNC-ish or POSIX
|
||
// path is left with its slashes.
|
||
if decoded.get(1..2) == Some(":") {
|
||
decoded.replace('/', "\\")
|
||
} else {
|
||
format!("/{decoded}")
|
||
}
|
||
}
|
||
|
||
fn percent_decode(s: &str) -> String {
|
||
let bytes = s.as_bytes();
|
||
let mut out = Vec::with_capacity(bytes.len());
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
if bytes[i] == b'%' {
|
||
let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok());
|
||
if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||
out.push(v);
|
||
i += 3;
|
||
continue;
|
||
}
|
||
}
|
||
out.push(bytes[i]);
|
||
i += 1;
|
||
}
|
||
String::from_utf8_lossy(&out).into_owned()
|
||
}
|
||
|
||
// ---- SoulStream CSV v1 -----------------------------------------------------
|
||
|
||
/// RFC 4180 record reader: quotes, escaped quotes, embedded newlines. Total
|
||
/// decoded size is already bounded by MAX_SOURCE_BYTES.
|
||
fn csv_records(text: &str) -> Vec<Vec<String>> {
|
||
let mut records = Vec::new();
|
||
let mut record = Vec::new();
|
||
let mut fieldbuf = String::new();
|
||
let mut in_quotes = false;
|
||
let mut chars = text.chars().peekable();
|
||
while let Some(c) = chars.next() {
|
||
if in_quotes {
|
||
match c {
|
||
'"' if chars.peek() == Some(&'"') => {
|
||
fieldbuf.push('"');
|
||
chars.next();
|
||
}
|
||
'"' => in_quotes = false,
|
||
other => fieldbuf.push(other),
|
||
}
|
||
continue;
|
||
}
|
||
match c {
|
||
'"' => in_quotes = true,
|
||
',' => record.push(std::mem::take(&mut fieldbuf)),
|
||
'\r' => {}
|
||
'\n' => {
|
||
record.push(std::mem::take(&mut fieldbuf));
|
||
if record.iter().any(|f| !f.trim().is_empty()) {
|
||
records.push(std::mem::take(&mut record));
|
||
} else {
|
||
record.clear();
|
||
}
|
||
if records.len() > MAX_ENTRIES + 8 {
|
||
break;
|
||
}
|
||
}
|
||
other => fieldbuf.push(other),
|
||
}
|
||
}
|
||
if !fieldbuf.is_empty() || !record.is_empty() {
|
||
record.push(fieldbuf);
|
||
if record.iter().any(|f| !f.trim().is_empty()) {
|
||
records.push(record);
|
||
}
|
||
}
|
||
records
|
||
}
|
||
|
||
/// Strip the formula-injection guard the CSV writer adds (a leading `'`
|
||
/// before `=`, `+`, `-`, `@`). Documented, reversible.
|
||
fn unescape_csv_cell(s: &str) -> String {
|
||
let t = s.trim();
|
||
if let Some(rest) = t.strip_prefix('\'') {
|
||
if rest.starts_with(['=', '+', '-', '@']) {
|
||
return rest.to_string();
|
||
}
|
||
}
|
||
t.to_string()
|
||
}
|
||
|
||
fn parse_csv(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
||
let mut warnings = Vec::new();
|
||
let text = decode(bytes, &mut warnings);
|
||
let records = csv_records(&text);
|
||
let Some(header) = records.first() else {
|
||
return Err("Empty CSV file".into());
|
||
};
|
||
let idx = |names: &[&str]| -> Option<usize> {
|
||
header.iter().position(|h| {
|
||
let h = h.trim().to_ascii_lowercase();
|
||
names.iter().any(|n| h == *n)
|
||
})
|
||
};
|
||
let col_title = idx(&["title", "track", "song", "name"]);
|
||
let col_artist = idx(&["artist", "artists", "artist name(s)", "creator"]);
|
||
let col_album = idx(&["album", "release"]);
|
||
let col_dur_ms = idx(&["duration_ms", "duration (ms)"]);
|
||
let col_dur_s = idx(&["duration_secs", "duration", "length"]);
|
||
let col_path = idx(&["path", "location", "file", "locator"]);
|
||
let col_start = idx(&["start_ms", "start"]);
|
||
if col_title.is_none() && col_path.is_none() {
|
||
return Err("CSV has no recognizable title or path column".into());
|
||
}
|
||
let get = |record: &[String], col: Option<usize>| -> Option<String> {
|
||
col.and_then(|c| record.get(c)).and_then(|v| non_empty(&unescape_csv_cell(v)))
|
||
};
|
||
let mut entries = Vec::new();
|
||
for record in records.iter().skip(1) {
|
||
if entries.len() >= MAX_ENTRIES {
|
||
warnings.push(format!("Stopped after {MAX_ENTRIES} entries"));
|
||
break;
|
||
}
|
||
// Our own export writes a playlist metadata row; skip it on re-import.
|
||
if record.first().map(|f| f.trim()) == Some("playlist") {
|
||
continue;
|
||
}
|
||
let duration_secs = get(record, col_dur_ms)
|
||
.and_then(|v| v.parse::<u64>().ok())
|
||
.map(|ms| (ms / 1000) as u32)
|
||
.or_else(|| get(record, col_dur_s).and_then(|v| v.parse::<u32>().ok()))
|
||
.filter(|s| *s > 0);
|
||
let entry = ParsedEntry {
|
||
ordinal: entries.len() as u32,
|
||
title: get(record, col_title),
|
||
artist: get(record, col_artist),
|
||
album: get(record, col_album),
|
||
duration_secs,
|
||
location: get(record, col_path),
|
||
start_ms: get(record, col_start).and_then(|v| v.parse::<i64>().ok()),
|
||
raw: clip(&record.join(", ")),
|
||
};
|
||
if entry.title.is_none() && entry.location.is_none() {
|
||
continue;
|
||
}
|
||
entries.push(entry);
|
||
}
|
||
Ok(ParsedPlaylist {
|
||
format: "csv".into(),
|
||
title: None,
|
||
description: None,
|
||
entries,
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
// ---- SoulStream JSON v1 ----------------------------------------------------
|
||
|
||
const SOULSTREAM_JSON_FORMAT: &str = "soulstream-playlist";
|
||
|
||
fn parse_soulstream_json(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
||
let text = std::str::from_utf8(bytes).map_err(|_| "JSON must be UTF-8".to_string())?;
|
||
let value: serde_json::Value =
|
||
serde_json::from_str(text).map_err(|e| format!("Invalid JSON: {e}"))?;
|
||
let format = value.get("format").and_then(|v| v.as_str()).unwrap_or("");
|
||
if format != SOULSTREAM_JSON_FORMAT {
|
||
return Err("Not a SoulStream playlist JSON file (missing format marker)".into());
|
||
}
|
||
let version = value.get("version").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
if version != 1 {
|
||
return Err(format!("Unsupported SoulStream playlist version {version}"));
|
||
}
|
||
let mut warnings = Vec::new();
|
||
let s = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_str()).and_then(non_empty);
|
||
let mut entries = Vec::new();
|
||
for item in value
|
||
.get("items")
|
||
.and_then(|v| v.as_array())
|
||
.map(|a| a.as_slice())
|
||
.unwrap_or_default()
|
||
{
|
||
if entries.len() >= MAX_ENTRIES {
|
||
warnings.push(format!("Stopped after {MAX_ENTRIES} entries"));
|
||
break;
|
||
}
|
||
entries.push(ParsedEntry {
|
||
ordinal: entries.len() as u32,
|
||
title: s(item.get("title")),
|
||
artist: s(item.get("artist")),
|
||
album: s(item.get("album")),
|
||
duration_secs: item
|
||
.get("durationSecs")
|
||
.and_then(|v| v.as_u64())
|
||
.map(|v| v as u32),
|
||
location: s(item.get("path")),
|
||
start_ms: item.get("startMs").and_then(|v| v.as_i64()),
|
||
raw: s(item.get("sourceLine")).unwrap_or_default(),
|
||
});
|
||
}
|
||
Ok(ParsedPlaylist {
|
||
format: "soulstream".into(),
|
||
title: s(value.get("name")),
|
||
description: s(value.get("description")),
|
||
entries,
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
// ---- pasted text tracklists ------------------------------------------------
|
||
|
||
/// Parse a pasted tracklist. Recognized line shapes, in the order tried:
|
||
///
|
||
/// ```text
|
||
/// 12. 1:23:45 Artist - Title (index + timestamp + split)
|
||
/// [01:23] Artist — Title (Remix)
|
||
/// 01:23 Artist - Title
|
||
/// Artist - Title 01:23 (trailing timestamp, YouTube style)
|
||
/// Artist - Title
|
||
/// 07. Title
|
||
/// ID - ID (unknown-track placeholder)
|
||
/// Some words (title only)
|
||
/// ```
|
||
///
|
||
/// A timestamp anywhere makes the whole list a SETLIST: order plus where each
|
||
/// entry begins in the source recording.
|
||
pub fn parse_text(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
||
let mut warnings = Vec::new();
|
||
let text = decode(bytes, &mut warnings);
|
||
let mut entries = Vec::new();
|
||
for line in text.lines() {
|
||
let raw = line.trim();
|
||
if raw.is_empty() || raw.len() > 4096 {
|
||
continue;
|
||
}
|
||
// Section headers people paste along ("Tracklist:", "--- Set 2 ---").
|
||
let bare = raw.trim_matches(['-', '=', '*', '#', ' ']);
|
||
if bare.is_empty() || bare.eq_ignore_ascii_case("tracklist") || bare.eq_ignore_ascii_case("tracklist:") {
|
||
continue;
|
||
}
|
||
if entries.len() >= MAX_ENTRIES {
|
||
warnings.push(format!("Stopped after {MAX_ENTRIES} entries"));
|
||
break;
|
||
}
|
||
let mut rest = raw;
|
||
// Leading index: "12." / "12)" / "12 -" (max 4 digits).
|
||
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||
if !digits.is_empty() && digits.len() <= 4 {
|
||
let after = &rest[digits.len()..];
|
||
if let Some(stripped) = after
|
||
.strip_prefix('.')
|
||
.or_else(|| after.strip_prefix(')'))
|
||
{
|
||
rest = stripped.trim_start();
|
||
} else if after.starts_with(" - ") && parse_timestamp(&digits).is_none() {
|
||
// "12 - Title" is an index only when 12 can't be a timestamp
|
||
// (bare minutes are never written without seconds).
|
||
rest = after[3..].trim_start();
|
||
}
|
||
}
|
||
// Timestamp at the start: "[1:23:45]" "(01:23)" "01:23" "1:23:45".
|
||
let mut start_ms = None;
|
||
let unbracketed = rest
|
||
.strip_prefix('[')
|
||
.and_then(|r| r.split_once(']'))
|
||
.or_else(|| rest.strip_prefix('(').and_then(|r| r.split_once(')')));
|
||
if let Some((ts, after)) = unbracketed {
|
||
if let Some(ms) = parse_timestamp(ts.trim()) {
|
||
start_ms = Some(ms);
|
||
rest = after.trim_start();
|
||
}
|
||
} else {
|
||
let token = rest.split_whitespace().next().unwrap_or_default();
|
||
if let Some(ms) = parse_timestamp(token) {
|
||
start_ms = Some(ms);
|
||
rest = rest[token.len()..].trim_start();
|
||
}
|
||
}
|
||
// Trailing timestamp: "Artist - Title 1:23:45" / "… (01:23)".
|
||
if start_ms.is_none() {
|
||
let trimmed = rest.trim_end();
|
||
if let Some(pos) = trimmed.rfind(char::is_whitespace) {
|
||
let tail = trimmed[pos..].trim().trim_matches(['(', ')', '[', ']']);
|
||
if let Some(ms) = parse_timestamp(tail) {
|
||
start_ms = Some(ms);
|
||
rest = trimmed[..pos].trim_end();
|
||
}
|
||
}
|
||
}
|
||
rest = rest.trim_start_matches(['-', '–', '—', ' ']).trim();
|
||
if rest.is_empty() {
|
||
continue;
|
||
}
|
||
let (artist, title) = split_artist_title(rest);
|
||
let is_id = rest.eq_ignore_ascii_case("id - id") || rest.eq_ignore_ascii_case("id — id");
|
||
entries.push(ParsedEntry {
|
||
ordinal: entries.len() as u32,
|
||
title: if is_id { Some("ID".into()) } else { title.or_else(|| non_empty(rest)) },
|
||
artist: if is_id { None } else { artist },
|
||
album: None,
|
||
duration_secs: None,
|
||
location: None,
|
||
start_ms,
|
||
raw: clip(raw),
|
||
});
|
||
}
|
||
Ok(ParsedPlaylist {
|
||
format: "text".into(),
|
||
title: None,
|
||
description: None,
|
||
entries,
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
/// "1:23" → 83_000 ms; "1:23:45" → 5_025_000; rejects anything else.
|
||
pub(crate) fn parse_timestamp(s: &str) -> Option<i64> {
|
||
let parts: Vec<&str> = s.split(':').collect();
|
||
if !(2..=3).contains(&parts.len()) {
|
||
return None;
|
||
}
|
||
let mut total: i64 = 0;
|
||
for (i, part) in parts.iter().enumerate() {
|
||
if part.is_empty() || part.len() > 3 || !part.chars().all(|c| c.is_ascii_digit()) {
|
||
return None;
|
||
}
|
||
// Only the leading field may exceed 59.
|
||
let v: i64 = part.parse().ok()?;
|
||
if i > 0 && v > 59 {
|
||
return None;
|
||
}
|
||
total = total * 60 + v;
|
||
}
|
||
Some(total * 1000)
|
||
}
|
||
|
||
// ---- writers ---------------------------------------------------------------
|
||
|
||
/// What the writers consume: the export view of one playlist row.
|
||
pub struct ExportItem<'a> {
|
||
pub title: &'a str,
|
||
pub artist: &'a str,
|
||
pub album: Option<&'a str>,
|
||
pub duration_secs: Option<u32>,
|
||
pub path: Option<&'a str>,
|
||
pub start_ms: Option<i64>,
|
||
pub source_line: Option<&'a str>,
|
||
pub kind: &'a str,
|
||
pub note: Option<&'a str>,
|
||
}
|
||
|
||
/// Line-oriented formats cannot hold a newline inside a value: a title
|
||
/// containing one would inject phantom lines (a crafted import could smuggle
|
||
/// a `File2=` into a later PLS export). Control characters flatten to spaces.
|
||
fn line_safe(s: &str) -> String {
|
||
s.chars()
|
||
.map(|c| if c.is_control() { ' ' } else { c })
|
||
.collect()
|
||
}
|
||
|
||
pub fn write_m3u8(items: &[ExportItem<'_>]) -> String {
|
||
let mut out = String::from("#EXTM3U\n");
|
||
for item in items {
|
||
let display = line_safe(&if item.artist.is_empty() {
|
||
item.title.to_string()
|
||
} else {
|
||
format!("{} - {}", item.artist, item.title)
|
||
});
|
||
let dur = item.duration_secs.map(|d| d as i64).unwrap_or(-1);
|
||
let _ = writeln!(out, "#EXTINF:{dur},{display}");
|
||
match item.path {
|
||
Some(path) => {
|
||
let _ = writeln!(out, "{}", line_safe(path));
|
||
}
|
||
None => {
|
||
// An unresolved row has no locator; a comment keeps its place
|
||
// visible instead of silently shrinking the list.
|
||
let _ = writeln!(out, "# unresolved: {display}");
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// PLS carries locators only, so unresolved rows cannot be represented —
|
||
/// they are skipped and the caller's UI owes the user that count.
|
||
pub fn write_pls(items: &[ExportItem<'_>]) -> String {
|
||
let mut out = String::from("[playlist]\n");
|
||
let mut n = 0u32;
|
||
for item in items {
|
||
let Some(path) = item.path else { continue };
|
||
n += 1;
|
||
let display = line_safe(&if item.artist.is_empty() {
|
||
item.title.to_string()
|
||
} else {
|
||
format!("{} - {}", item.artist, item.title)
|
||
});
|
||
let _ = writeln!(out, "File{n}={}", line_safe(path));
|
||
let _ = writeln!(out, "Title{n}={display}");
|
||
let _ = writeln!(
|
||
out,
|
||
"Length{n}={}",
|
||
item.duration_secs.map(i64::from).unwrap_or(-1)
|
||
);
|
||
}
|
||
let _ = writeln!(out, "NumberOfEntries={n}");
|
||
out.push_str("Version=2\n");
|
||
out
|
||
}
|
||
|
||
fn xml_escape(s: &str) -> String {
|
||
s.replace('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
.replace('"', """)
|
||
}
|
||
|
||
/// Windows path → file URI for XSPF `location`.
|
||
fn path_to_file_uri(path: &str) -> String {
|
||
let slashes = path.replace('\\', "/");
|
||
let mut encoded = String::with_capacity(slashes.len() + 8);
|
||
for c in slashes.chars() {
|
||
match c {
|
||
'A'..='Z' | 'a'..='z' | '0'..='9' | '/' | ':' | '.' | '-' | '_' | '~' | '(' | ')'
|
||
| '\'' | '!' | '&' | ',' | '=' | '+' | '$' | '@' | ';' => encoded.push(c),
|
||
' ' => encoded.push_str("%20"),
|
||
other => {
|
||
let mut buf = [0u8; 4];
|
||
for b in other.encode_utf8(&mut buf).as_bytes() {
|
||
let _ = write!(encoded, "%{b:02X}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
format!("file:///{encoded}")
|
||
}
|
||
|
||
pub fn write_xspf(title: &str, description: Option<&str>, items: &[ExportItem<'_>]) -> String {
|
||
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||
out.push_str("<playlist version=\"1\" xmlns=\"http://xspf.org/ns/0/\">\n");
|
||
let _ = writeln!(out, " <title>{}</title>", xml_escape(title));
|
||
if let Some(d) = description {
|
||
let _ = writeln!(out, " <annotation>{}</annotation>", xml_escape(d));
|
||
}
|
||
out.push_str(" <trackList>\n");
|
||
for item in items {
|
||
out.push_str(" <track>\n");
|
||
if let Some(path) = item.path {
|
||
let _ = writeln!(out, " <location>{}</location>", xml_escape(&path_to_file_uri(path)));
|
||
}
|
||
let _ = writeln!(out, " <title>{}</title>", xml_escape(item.title));
|
||
if !item.artist.is_empty() {
|
||
let _ = writeln!(out, " <creator>{}</creator>", xml_escape(item.artist));
|
||
}
|
||
if let Some(album) = item.album {
|
||
let _ = writeln!(out, " <album>{}</album>", xml_escape(album));
|
||
}
|
||
if let Some(secs) = item.duration_secs {
|
||
let _ = writeln!(out, " <duration>{}</duration>", secs as u64 * 1000);
|
||
}
|
||
out.push_str(" </track>\n");
|
||
}
|
||
out.push_str(" </trackList>\n</playlist>\n");
|
||
out
|
||
}
|
||
|
||
/// Quote per RFC 4180 and guard formula-leading cells (OWASP CSV injection):
|
||
/// a leading `=`, `+`, `-` or `@` gains a `'` prefix, stripped on re-import.
|
||
fn csv_cell(s: &str) -> String {
|
||
let guarded = if s.starts_with(['=', '+', '-', '@']) {
|
||
format!("'{s}")
|
||
} else {
|
||
s.to_string()
|
||
};
|
||
if guarded.contains([',', '"', '\n', '\r']) {
|
||
format!("\"{}\"", guarded.replace('"', "\"\""))
|
||
} else {
|
||
guarded
|
||
}
|
||
}
|
||
|
||
pub fn write_csv(playlist_name: &str, items: &[ExportItem<'_>]) -> String {
|
||
let mut out = String::from(
|
||
"record_type,position,title,artist,album,duration_secs,path,start_ms,kind,note\n",
|
||
);
|
||
let _ = writeln!(out, "playlist,,{},,,,,,,", csv_cell(playlist_name));
|
||
for (position, item) in items.iter().enumerate() {
|
||
let _ = writeln!(
|
||
out,
|
||
"entry,{position},{},{},{},{},{},{},{},{}",
|
||
csv_cell(item.title),
|
||
csv_cell(item.artist),
|
||
csv_cell(item.album.unwrap_or("")),
|
||
item.duration_secs.map(|d| d.to_string()).unwrap_or_default(),
|
||
csv_cell(item.path.unwrap_or("")),
|
||
item.start_ms.map(|v| v.to_string()).unwrap_or_default(),
|
||
item.kind,
|
||
csv_cell(item.note.unwrap_or("")),
|
||
);
|
||
}
|
||
out
|
||
}
|
||
|
||
pub fn write_soulstream_json(
|
||
name: &str,
|
||
description: Option<&str>,
|
||
kind: &str,
|
||
items: &[ExportItem<'_>],
|
||
) -> String {
|
||
let items: Vec<serde_json::Value> = items
|
||
.iter()
|
||
.map(|item| {
|
||
serde_json::json!({
|
||
"title": item.title,
|
||
"artist": item.artist,
|
||
"album": item.album,
|
||
"durationSecs": item.duration_secs,
|
||
"path": item.path,
|
||
"startMs": item.start_ms,
|
||
"sourceLine": item.source_line,
|
||
"kind": item.kind,
|
||
"note": item.note,
|
||
})
|
||
})
|
||
.collect();
|
||
let doc = serde_json::json!({
|
||
"format": SOULSTREAM_JSON_FORMAT,
|
||
"version": 1,
|
||
"name": name,
|
||
"description": description,
|
||
"kind": kind,
|
||
"items": items,
|
||
});
|
||
serde_json::to_string_pretty(&doc).unwrap_or_default()
|
||
}
|
||
|
||
// ---- tests -----------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn extended_m3u_with_bom_crlf_and_duplicates() {
|
||
let src = "\u{feff}#EXTM3U\r\n#PLAYLIST:Road Mix\r\n#EXTINF:245,Radiohead - Airbag\r\nC:\\Music\\01 Airbag.flac\r\n#EXTINF:-1,Unknown Stream\r\nrelative\\02 - Paranoid Android.mp3\r\nC:\\Music\\01 Airbag.flac\r\n#EXTSOMETHING:ignored\r\n";
|
||
let p = parse("mix.m3u8", src.as_bytes()).unwrap();
|
||
assert_eq!(p.format, "m3u");
|
||
assert_eq!(p.title.as_deref(), Some("Road Mix"));
|
||
assert_eq!(p.entries.len(), 3);
|
||
assert_eq!(p.entries[0].artist.as_deref(), Some("Radiohead"));
|
||
assert_eq!(p.entries[0].title.as_deref(), Some("Airbag"));
|
||
assert_eq!(p.entries[0].duration_secs, Some(245));
|
||
assert_eq!(p.entries[1].title.as_deref(), Some("Unknown Stream"));
|
||
assert_eq!(p.entries[1].duration_secs, None, "-1 means unknown");
|
||
// The duplicate stays a distinct occurrence; bare line gets a stem title.
|
||
assert_eq!(p.entries[2].title.as_deref(), Some("Airbag"));
|
||
assert_eq!(p.entries[2].location.as_deref(), Some("C:\\Music\\01 Airbag.flac"));
|
||
}
|
||
|
||
#[test]
|
||
fn hls_manifests_are_refused() {
|
||
let src = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\nseg0.ts\n";
|
||
assert!(parse("stream.m3u8", src.as_bytes()).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_encoding_falls_back_with_warning() {
|
||
// "Café" in Windows-1252 — 0xE9 is not valid UTF-8.
|
||
let src = b"#EXTM3U\n#EXTINF:100,Caf\xe9 - Song\nC:\\m\\caf\xe9.mp3\n";
|
||
let p = parse("old.m3u", src).unwrap();
|
||
assert!(!p.warnings.is_empty());
|
||
assert_eq!(p.entries[0].artist.as_deref(), Some("Café"));
|
||
assert_eq!(p.entries[0].location.as_deref(), Some("C:\\m\\café.mp3"));
|
||
}
|
||
|
||
#[test]
|
||
fn pls_with_sparse_and_out_of_order_indices() {
|
||
let src = "[playlist]\nFile3=C:\\m\\three.mp3\nTitle3=C - Three\nFile1=C:\\m\\one.mp3\nLength1=200\nTitle1=A - One\nNumberOfEntries=9\nVersion=2\n";
|
||
let p = parse("list.pls", src.as_bytes()).unwrap();
|
||
assert_eq!(p.entries.len(), 2, "NumberOfEntries is not trusted");
|
||
assert_eq!(p.entries[0].title.as_deref(), Some("One"));
|
||
assert_eq!(p.entries[0].artist.as_deref(), Some("A"));
|
||
assert_eq!(p.entries[0].duration_secs, Some(200));
|
||
assert_eq!(p.entries[1].title.as_deref(), Some("Three"));
|
||
}
|
||
|
||
#[test]
|
||
fn xspf_roundtrip_and_file_uris() {
|
||
let items = [
|
||
ExportItem {
|
||
title: "Airbag & Co", artist: "Radiohead", album: Some("OK Computer"),
|
||
duration_secs: Some(245), path: Some("C:\\Mu sic\\01 Airbag.flac"),
|
||
start_ms: None, source_line: None, kind: "local", note: None,
|
||
},
|
||
ExportItem {
|
||
title: "Ghost", artist: "Nobody", album: None, duration_secs: None,
|
||
path: None, start_ms: None, source_line: None, kind: "unresolved", note: None,
|
||
},
|
||
];
|
||
let xml = write_xspf("My <List>", Some("desc"), &items);
|
||
let p = parse("list.xspf", xml.as_bytes()).unwrap();
|
||
assert_eq!(p.title.as_deref(), Some("My <List>"));
|
||
assert_eq!(p.description.as_deref(), Some("desc"));
|
||
assert_eq!(p.entries.len(), 2);
|
||
assert_eq!(p.entries[0].title.as_deref(), Some("Airbag & Co"));
|
||
assert_eq!(p.entries[0].artist.as_deref(), Some("Radiohead"));
|
||
assert_eq!(p.entries[0].album.as_deref(), Some("OK Computer"));
|
||
assert_eq!(p.entries[0].duration_secs, Some(245));
|
||
assert_eq!(
|
||
p.entries[0].location.as_deref(),
|
||
Some("C:\\Mu sic\\01 Airbag.flac"),
|
||
"file URI decodes back to the Windows path"
|
||
);
|
||
assert_eq!(p.entries[1].location, None, "unresolved rows keep their place");
|
||
}
|
||
|
||
#[test]
|
||
fn xspf_with_dtd_is_refused() {
|
||
let src = "<?xml version=\"1.0\"?><!DOCTYPE x [<!ENTITY e SYSTEM \"file:///etc/passwd\">]><playlist/>";
|
||
assert!(parse("evil.xspf", src.as_bytes()).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn csv_roundtrip_with_quotes_and_formula_guard() {
|
||
let items = [
|
||
ExportItem {
|
||
title: "=SUM(A1)", artist: "Quote \"Man\"", album: Some("With, comma"),
|
||
duration_secs: Some(90), path: Some("C:\\m\\a.mp3"),
|
||
start_ms: Some(61_000), source_line: None, kind: "local", note: None,
|
||
},
|
||
];
|
||
let csv = write_csv("My list", &items);
|
||
let p = parse("list.csv", csv.as_bytes()).unwrap();
|
||
assert_eq!(p.entries.len(), 1);
|
||
assert_eq!(p.entries[0].title.as_deref(), Some("=SUM(A1)"), "guard strips on import");
|
||
assert_eq!(p.entries[0].artist.as_deref(), Some("Quote \"Man\""));
|
||
assert_eq!(p.entries[0].album.as_deref(), Some("With, comma"));
|
||
assert_eq!(p.entries[0].duration_secs, Some(90));
|
||
assert_eq!(p.entries[0].start_ms, Some(61_000));
|
||
}
|
||
|
||
#[test]
|
||
fn soulstream_json_roundtrip_is_lossless() {
|
||
let items = [
|
||
ExportItem {
|
||
title: "Opener", artist: "DJ", album: None, duration_secs: Some(240),
|
||
path: None, start_ms: Some(0), source_line: Some("00:00 DJ - Opener"),
|
||
kind: "unresolved", note: Some("white label"),
|
||
},
|
||
];
|
||
let json = write_soulstream_json("Warehouse set", Some("Rip"), "setlist", &items);
|
||
let p = parse("set.json", json.as_bytes()).unwrap();
|
||
assert_eq!(p.format, "soulstream");
|
||
assert_eq!(p.title.as_deref(), Some("Warehouse set"));
|
||
assert_eq!(p.entries[0].start_ms, Some(0));
|
||
assert_eq!(p.entries[0].raw, "00:00 DJ - Opener");
|
||
}
|
||
|
||
#[test]
|
||
fn unversioned_json_is_refused() {
|
||
assert!(parse("x.json", b"{\"items\":[]}").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn text_tracklist_variants() {
|
||
let src = "Tracklist:\n00:00 Bicep - Glue\n[05:30] Overmono — So U Kno (Remix)\n3. Four Tet - Baby\nID - ID\nDJ Seinfeld - U 1:02:03\nJust A Title\n";
|
||
let p = parse_text(src.as_bytes()).unwrap();
|
||
assert_eq!(p.entries.len(), 6);
|
||
assert_eq!(p.entries[0].start_ms, Some(0));
|
||
assert_eq!(p.entries[0].artist.as_deref(), Some("Bicep"));
|
||
assert_eq!(p.entries[0].title.as_deref(), Some("Glue"));
|
||
assert_eq!(p.entries[1].start_ms, Some(330_000));
|
||
assert_eq!(p.entries[1].title.as_deref(), Some("So U Kno (Remix)"));
|
||
assert_eq!(p.entries[2].artist.as_deref(), Some("Four Tet"), "leading index stripped");
|
||
assert_eq!(p.entries[2].start_ms, None);
|
||
assert_eq!(p.entries[3].title.as_deref(), Some("ID"), "unknown placeholder");
|
||
assert_eq!(p.entries[4].start_ms, Some(3_723_000), "trailing timestamp");
|
||
assert_eq!(p.entries[4].artist.as_deref(), Some("DJ Seinfeld"));
|
||
assert_eq!(p.entries[5].title.as_deref(), Some("Just A Title"));
|
||
}
|
||
|
||
#[test]
|
||
fn m3u8_writer_roundtrips_and_keeps_unresolved_visible() {
|
||
let items = [
|
||
ExportItem {
|
||
title: "One", artist: "A", album: None, duration_secs: Some(100),
|
||
path: Some("C:\\m\\one.mp3"), start_ms: None, source_line: None,
|
||
kind: "local", note: None,
|
||
},
|
||
ExportItem {
|
||
title: "Ghost", artist: "B", album: None, duration_secs: None,
|
||
path: None, start_ms: None, source_line: None, kind: "unresolved", note: None,
|
||
},
|
||
];
|
||
let m3u = write_m3u8(&items);
|
||
assert!(m3u.contains("# unresolved: B - Ghost"));
|
||
let p = parse("out.m3u8", m3u.as_bytes()).unwrap();
|
||
assert_eq!(p.entries.len(), 1, "the unresolved comment is not an entry");
|
||
assert_eq!(p.entries[0].title.as_deref(), Some("One"));
|
||
}
|
||
|
||
#[test]
|
||
fn pls_writer_roundtrips_and_skips_unresolved() {
|
||
let items = [
|
||
ExportItem {
|
||
title: "One", artist: "A", album: None, duration_secs: Some(100),
|
||
path: Some("C:\\m\\one.mp3"), start_ms: None, source_line: None,
|
||
kind: "local", note: None,
|
||
},
|
||
ExportItem {
|
||
title: "Ghost", artist: "B", album: None, duration_secs: None,
|
||
path: None, start_ms: None, source_line: None, kind: "unresolved", note: None,
|
||
},
|
||
ExportItem {
|
||
title: "Two", artist: "A", album: None, duration_secs: None,
|
||
path: Some("C:\\m\\two.mp3"), start_ms: None, source_line: None,
|
||
kind: "local", note: None,
|
||
},
|
||
];
|
||
let pls = write_pls(&items);
|
||
assert!(pls.contains("NumberOfEntries=2"));
|
||
let parsed = parse("out.pls", pls.as_bytes()).unwrap();
|
||
assert_eq!(parsed.entries.len(), 2);
|
||
assert_eq!(parsed.entries[0].title.as_deref(), Some("One"));
|
||
assert_eq!(parsed.entries[0].artist.as_deref(), Some("A"));
|
||
assert_eq!(parsed.entries[0].duration_secs, Some(100));
|
||
assert_eq!(parsed.entries[1].location.as_deref(), Some("C:\\m\\two.mp3"));
|
||
assert_eq!(parsed.entries[1].duration_secs, None, "-1 stays unknown");
|
||
}
|
||
|
||
#[test]
|
||
fn oversized_sources_are_refused() {
|
||
let big = vec![b'a'; MAX_SOURCE_BYTES + 1];
|
||
assert!(parse("big.m3u", &big).is_err());
|
||
}
|
||
}
|