feat(music): W22 shared listening + release 0.6.0
Personal playlist on a dedicated music player (browse/play/prev/next/ seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared listening: broadcast your track over presence, peers tune in and stream it point-to-point over the files plane. Playback is timeline-synced (play/pause/skip/seek mirror with no drift) with gapless prefetch of the next track and independent per-source volume per listener. In the 3-column layout the playlist gets its own card stacked under the chat, with a resizable divider and its own scrollbar; other layouts keep it in the Controls panel. Breaking wire change: gossip protocol v5 (presence gains music fields), so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 -> 0.6.0; CHANGELOG updated. Untrusted-input handling: broadcast track name sanitized and size cap-checked at gossip ingest, fetched bytes confirmed audio before decode, only the descriptor rides gossip (bytes go point-to-point, one fetch in flight). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user