The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
3.4 KiB
Rust
116 lines
3.4 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum PlaylistKind {
|
|
M3u,
|
|
Pls,
|
|
}
|
|
|
|
/// Classify a path by extension into a playlist kind, or None if it is not a
|
|
/// recognized playlist file. Case-insensitive: m3u/m3u8 -> M3u, pls -> Pls.
|
|
pub fn playlist_kind(path: &Path) -> Option<PlaylistKind> {
|
|
let ext = path.extension()?.to_string_lossy();
|
|
match ext.to_ascii_lowercase().as_str() {
|
|
"m3u" | "m3u8" => Some(PlaylistKind::M3u),
|
|
"pls" => Some(PlaylistKind::Pls),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Parse an m3u/m3u8 or pls playlist into local audio file paths. Remote entries
|
|
/// (http/https/ftp URLs) and non-audio entries are skipped; relative paths are
|
|
/// resolved against `base_dir` (the playlist file's parent directory). Order is
|
|
/// preserved. Does not touch the filesystem.
|
|
pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Vec<PathBuf> {
|
|
let entries: Vec<&str> = match kind {
|
|
PlaylistKind::M3u => contents
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
|
.collect(),
|
|
PlaylistKind::Pls => contents
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let (key, value) = line.split_once('=')?;
|
|
key.trim()
|
|
.to_ascii_lowercase()
|
|
.starts_with("file")
|
|
.then_some(value.trim())
|
|
})
|
|
.filter(|line| !line.is_empty())
|
|
.collect(),
|
|
};
|
|
|
|
entries
|
|
.into_iter()
|
|
.filter_map(|entry| playlist_entry_path(entry, base_dir))
|
|
.collect()
|
|
}
|
|
|
|
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
|
|
let lower = entry.to_ascii_lowercase();
|
|
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://")
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let path = Path::new(entry);
|
|
let resolved = if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else {
|
|
base_dir.join(path)
|
|
};
|
|
let file_name = resolved.file_name()?.to_string_lossy();
|
|
crate::files::looks_like_audio_name(&file_name).then_some(resolved)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn m3u_skips_comments_and_remote_urls() {
|
|
let base = Path::new("/music/lists");
|
|
let contents = "\
|
|
#EXTM3U
|
|
#EXTINF:123,Artist - Song
|
|
tracks/song.ogg
|
|
https://example.com/stream.mp3
|
|
";
|
|
assert_eq!(
|
|
parse_playlist(contents, base, PlaylistKind::M3u),
|
|
vec![PathBuf::from("/music/lists/tracks/song.ogg")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pls_keeps_file_values_and_skips_non_audio() {
|
|
let base = Path::new("/music");
|
|
let contents = "\
|
|
[playlist]
|
|
File1=one.flac
|
|
Title1=One
|
|
File2=notes.txt
|
|
File3=/var/audio/two.MP3
|
|
";
|
|
assert_eq!(
|
|
parse_playlist(contents, base, PlaylistKind::Pls),
|
|
vec![
|
|
PathBuf::from("/music/one.flac"),
|
|
PathBuf::from("/var/audio/two.MP3"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn playlist_kind_is_case_insensitive() {
|
|
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
|
|
assert_eq!(
|
|
playlist_kind(Path::new("mix.m3u8")),
|
|
Some(PlaylistKind::M3u)
|
|
);
|
|
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
|
|
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
|
|
}
|
|
}
|