Files
SoulStream/src-tauri/src/playlist_formats.rs
T

1198 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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());
}
}