//! 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, pub description: Option, pub entries: Vec, pub warnings: Vec, } /// 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, pub artist: Option, pub album: Option, pub duration_secs: Option, pub location: Option, /// Setlist timestamp (where in a continuous source this entry starts). pub start_ms: Option, 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 { // 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 = 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 { 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(" Result { 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 { 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, Option, Option)> = 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::() .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, Option) { 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 { 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 { 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 = 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::().ok()), k if k.starts_with("title") => ("title", k[5..].parse::().ok()), k if k.starts_with("length") => ("length", k[6..].parse::().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::().ok().filter(|v| *v > 0).map(|v| v as u32); } _ => {} } } let entries: Vec = 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 { use quick_xml::events::Event; let mut warnings = Vec::new(); let text = decode(bytes, &mut warnings); if text.contains(" = 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, description: &mut Option, ) { 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::() .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> { 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 { 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 { 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| -> Option { 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::().ok()) .map(|ms| (ms / 1000) as u32) .or_else(|| get(record, col_dur_s).and_then(|v| v.parse::().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::().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 { 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 { 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 { 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, pub path: Option<&'a str>, pub start_ms: Option, 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("\n"); out.push_str("\n"); let _ = writeln!(out, " {}", xml_escape(title)); if let Some(d) = description { let _ = writeln!(out, " {}", xml_escape(d)); } out.push_str(" \n"); for item in items { out.push_str(" \n"); if let Some(path) = item.path { let _ = writeln!(out, " {}", xml_escape(&path_to_file_uri(path))); } let _ = writeln!(out, " {}", xml_escape(item.title)); if !item.artist.is_empty() { let _ = writeln!(out, " {}", xml_escape(item.artist)); } if let Some(album) = item.album { let _ = writeln!(out, " {}", xml_escape(album)); } if let Some(secs) = item.duration_secs { let _ = writeln!(out, " {}", secs as u64 * 1000); } out.push_str(" \n"); } out.push_str(" \n\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 = 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 ", Some("desc"), &items); let p = parse("list.xspf", xml.as_bytes()).unwrap(); assert_eq!(p.title.as_deref(), Some("My ")); 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 = "]>"; 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()); } }