use std::{collections::HashMap, mem, path::Path, time::Duration};
use mpd_protocol::response::Frame;
use crate::{
commands::{SongId, SongPosition},
responses::{parse_duration, FromFieldValue, Timestamp, TypedResponseError},
tag::Tag,
};
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SongInQueue {
pub position: SongPosition,
pub id: SongId,
pub range: Option<SongRange>,
pub priority: u8,
pub song: Song,
}
impl SongInQueue {
pub(crate) fn from_frame_single(
frame: Frame,
) -> Result<Option<SongInQueue>, TypedResponseError> {
let mut builder = SongBuilder::default();
for (key, value) in frame {
builder.field(&key, value)?;
}
Ok(builder.finish())
}
pub(crate) fn from_frame_multi(frame: Frame) -> Result<Vec<SongInQueue>, TypedResponseError> {
let mut out = Vec::new();
let mut builder = SongBuilder::default();
for (key, value) in frame {
if let Some(song) = builder.field(&key, value)? {
out.push(song);
}
}
if let Some(song) = builder.finish() {
out.push(song);
}
Ok(out)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Song {
pub url: String,
pub duration: Option<Duration>,
pub tags: HashMap<Tag, Vec<String>>,
pub format: Option<String>,
pub last_modified: Option<Timestamp>,
}
impl Song {
pub fn file_path(&self) -> &Path {
Path::new(&self.url)
}
pub fn artists(&self) -> &[String] {
self.tag_values(&Tag::Artist)
}
pub fn album_artists(&self) -> &[String] {
self.tag_values(&Tag::AlbumArtist)
}
pub fn album(&self) -> Option<&str> {
self.single_tag_value(&Tag::Album)
}
pub fn title(&self) -> Option<&str> {
self.single_tag_value(&Tag::Title)
}
pub fn number(&self) -> (u64, u64) {
let disc = self.single_tag_value(&Tag::Disc);
let track = self.single_tag_value(&Tag::Track);
(
disc.and_then(|v| v.parse().ok()).unwrap_or(0),
track.and_then(|v| v.parse().ok()).unwrap_or(0),
)
}
pub(crate) fn from_frame_multi(frame: Frame) -> Result<Vec<Song>, TypedResponseError> {
let mut out = Vec::new();
let mut builder = SongBuilder::default();
for (key, value) in frame {
if let Some(SongInQueue { song, .. }) = builder.field(&key, value)? {
out.push(song);
}
}
if let Some(SongInQueue { song, .. }) = builder.finish() {
out.push(song);
}
Ok(out)
}
fn tag_values(&self, tag: &Tag) -> &[String] {
match self.tags.get(tag) {
Some(v) => v.as_slice(),
None => &[],
}
}
fn single_tag_value(&self, tag: &Tag) -> Option<&str> {
match self.tag_values(tag) {
[] => None,
[v, ..] => Some(v),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SongRange {
pub from: Duration,
pub to: Option<Duration>,
}
impl FromFieldValue for SongRange {
fn from_value(v: String, field: &str) -> Result<Self, TypedResponseError> {
let Some((from, to)) = v.split_once('-') else {
return Err(TypedResponseError::invalid_value(field, v));
};
let from = parse_duration(field, from)?;
let to = if to.is_empty() {
None
} else {
Some(parse_duration(field, to)?)
};
Ok(SongRange { from, to })
}
}
#[derive(Debug, Default)]
struct SongBuilder {
url: String,
position: usize,
id: u64,
range: Option<SongRange>,
priority: u8,
duration: Option<Duration>,
tags: HashMap<Tag, Vec<String>>,
format: Option<String>,
last_modified: Option<Timestamp>,
}
impl SongBuilder {
fn field(
&mut self,
key: &str,
value: String,
) -> Result<Option<SongInQueue>, TypedResponseError> {
if self.url.is_empty() {
self.handle_start_field(key, value)?;
Ok(None)
} else {
self.handle_song_field(key, value)
}
}
fn handle_start_field(&mut self, key: &str, value: String) -> Result<(), TypedResponseError> {
match key {
"file" => self.url = value,
"directory" | "playlist" | "Last-Modified" => (),
other => return Err(TypedResponseError::unexpected_field("file", other)),
}
Ok(())
}
fn handle_song_field(
&mut self,
key: &str,
value: String,
) -> Result<Option<SongInQueue>, TypedResponseError> {
if is_start_field(key) {
let song = mem::take(self).into_song();
self.handle_start_field(key, value)?;
return Ok(Some(song));
}
match key {
"duration" => self.duration = Some(Duration::from_value(value, "duration")?),
"Time" => {
if self.duration.is_none() {
self.duration = Some(Duration::from_value(value, "Time")?);
}
}
"Range" => self.range = Some(SongRange::from_value(value, "Range")?),
"Format" => self.format = Some(value),
"Last-Modified" => {
let lm = Timestamp::from_value(value, "Last-Modified")?;
self.last_modified = Some(lm);
}
"Prio" => self.priority = u8::from_value(value, "Prio")?,
"Pos" => self.position = usize::from_value(value, "Pos")?,
"Id" => self.id = u64::from_value(value, "Id")?,
tag => {
let tag = Tag::try_from(tag).unwrap();
self.tags.entry(tag).or_default().push(value);
}
}
Ok(None)
}
fn finish(self) -> Option<SongInQueue> {
if self.url.is_empty() {
None
} else {
Some(self.into_song())
}
}
fn into_song(self) -> SongInQueue {
assert!(!self.url.is_empty());
SongInQueue {
position: SongPosition(self.position),
id: SongId(self.id),
range: self.range,
priority: self.priority,
song: Song {
url: self.url,
duration: self.duration,
tags: self.tags,
format: self.format,
last_modified: self.last_modified,
},
}
}
}
fn is_start_field(f: &str) -> bool {
matches!(f, "file" | "directory" | "playlist")
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use super::*;
const TEST_TIMESTAMP: &str = "2020-06-12T17:53:00Z";
#[test]
fn song_builder() {
let mut builder = SongBuilder::default();
assert_matches!(builder.field("file", String::from("test.flac")), Ok(None));
assert_matches!(builder.field("duration", String::from("123.456")), Ok(None));
assert_matches!(
builder.field("Last-Modified", String::from(TEST_TIMESTAMP)),
Ok(None)
);
assert_matches!(builder.field("Title", String::from("Foo")), Ok(None));
assert_matches!(builder.field("Id", String::from("12")), Ok(None));
assert_matches!(builder.field("Pos", String::from("5")), Ok(None));
let song = builder
.field("file", String::from("foo.flac"))
.unwrap()
.unwrap();
assert_eq!(
song,
SongInQueue {
position: SongPosition(5),
id: SongId(12),
priority: 0,
range: None,
song: Song {
url: String::from("test.flac"),
duration: Some(Duration::from_secs_f64(123.456)),
format: None,
last_modified: Some(Timestamp::from_value(TEST_TIMESTAMP.into(), "").unwrap()),
tags: [(Tag::Title, vec![String::from("Foo")])].into(),
}
}
);
let song = builder.finish().unwrap();
assert_eq!(
song,
SongInQueue {
position: SongPosition(0),
id: SongId(0),
priority: 0,
range: None,
song: Song {
url: String::from("foo.flac"),
duration: None,
format: None,
last_modified: None,
tags: HashMap::new(),
}
}
);
}
#[test]
fn song_builder_unrelated_entries() {
let mut builder = SongBuilder::default();
assert_matches!(builder.field("playlist", String::from("foo.m3u")), Ok(None));
assert_matches!(builder.field("directory", String::from("foo")), Ok(None));
assert_matches!(
builder.field("Last-Modified", String::from(TEST_TIMESTAMP)),
Ok(None)
);
assert_matches!(builder.field("file", String::from("foo.flac")), Ok(None));
let song = builder
.field("directory", String::from("mep"))
.unwrap()
.unwrap();
assert_eq!(
song,
SongInQueue {
position: SongPosition(0),
id: SongId(0),
priority: 0,
range: None,
song: Song {
url: String::from("foo.flac"),
duration: None,
format: None,
last_modified: None,
tags: HashMap::new(),
}
}
);
assert_matches!(builder.finish(), None);
}
#[test]
fn song_builder_deprecated_time_field() {
let mut builder = SongBuilder::default();
assert_matches!(builder.field("file", String::from("foo.flac")), Ok(None));
assert_matches!(builder.field("Time", String::from("123")), Ok(None));
assert_eq!(builder.duration, Some(Duration::from_secs(123)));
assert_matches!(builder.field("duration", String::from("456.700")), Ok(None));
assert_eq!(builder.duration, Some(Duration::from_secs_f64(456.7)));
assert_matches!(builder.field("Time", String::from("123")), Ok(None));
assert_eq!(builder.duration, Some(Duration::from_secs_f64(456.7)));
let song = builder.finish().unwrap().song;
assert_eq!(
song,
Song {
url: String::from("foo.flac"),
format: None,
last_modified: None,
duration: Some(Duration::from_secs_f64(456.7)),
tags: HashMap::new(),
}
);
}
#[test]
fn parse_range() {
assert_eq!(
SongRange::from_value(String::from("1.500-5.642"), "Range").unwrap(),
SongRange {
from: Duration::from_secs_f64(1.5),
to: Some(Duration::from_secs_f64(5.642)),
}
);
assert_eq!(
SongRange::from_value(String::from("1.500-"), "Range").unwrap(),
SongRange {
from: Duration::from_secs_f64(1.5),
to: None,
}
);
assert_matches!(SongRange::from_value(String::from("foo"), "Range"), Err(_));
assert_matches!(
SongRange::from_value(String::from("1.000--5.000"), "Range"),
Err(_)
);
}
}