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 { 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 { 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 { 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); } }