457 lines
15 KiB
Rust
457 lines
15 KiB
Rust
use serde::de;
|
|
use std::collections::HashMap;
|
|
mod error;
|
|
use error::{Error, MpdResult};
|
|
use itertools::Itertools;
|
|
mod structs;
|
|
pub use structs::{File, Position, State, Stats, Status, Track, UnitResponse};
|
|
|
|
// some unprintable character to separate repeated keys
|
|
const SEPARATOR: char = '\x02';
|
|
|
|
/// Parse an interator of string slices into `T`,
|
|
/// returning Ok(T) if the data could be deserialized and the MPD response ended with `OK` or
|
|
/// `Error` if the deserialization failed or MPD sent an error message.
|
|
///
|
|
/// ```
|
|
/// # use mparsed::{Track, parse_response};
|
|
/// # use std::time::Duration;
|
|
/// let response = "file: 01 Track.flac
|
|
/// Last-Modified: 2018-03-07T13:11:43Z
|
|
/// duration: 123.45
|
|
/// Pos: 1
|
|
/// Id: 2
|
|
/// OK";
|
|
/// let parsed: Track = parse_response(response.lines()).unwrap();
|
|
///
|
|
/// assert_eq!(parsed.file, String::from("01 Track.flac"));
|
|
/// assert_eq!(parsed.duration, Some(Duration::from_secs_f64(123.45)));
|
|
/// ```
|
|
///
|
|
/// For responses that contain multiple instances of a struct (like playlists), see
|
|
/// [`parse_response_vec`].
|
|
///
|
|
/// [`parse_response_vec`]: fn.parse_response_vec.html
|
|
pub fn parse_response<'a, I: Iterator<Item = &'a str>, T: de::DeserializeOwned>(input: I) -> MpdResult<T> {
|
|
let mut map: HashMap<String, String> = HashMap::new();
|
|
for line in input {
|
|
if line.starts_with("OK") {
|
|
break;
|
|
} else if let Some(message) = line.strip_prefix("ACK") {
|
|
return Err(Error::from_str(message.trim()));
|
|
}
|
|
|
|
match line.splitn(2, ": ").next_tuple() {
|
|
Some((k, v)) => {
|
|
if let Some(existing) = map.get_mut(k) {
|
|
existing.push(SEPARATOR);
|
|
existing.push_str(v);
|
|
} else {
|
|
map.insert(k.to_string(), v.to_string());
|
|
}
|
|
}
|
|
_ => panic!("invalid response line: {:?}", line),
|
|
}
|
|
}
|
|
// Eventually, I’d like to use my own deserializer instead of envy
|
|
Ok(envy::from_iter(map).unwrap())
|
|
}
|
|
|
|
/// Parse an iterator of string slices into a vector of `T`, splitting at any occurence of `first_key`.
|
|
///
|
|
/// One possible use for this is the `playlistinfo` command which returns all items in the current
|
|
/// playlist, where the `file: ` key denotes the start of a new item.
|
|
///
|
|
/// ## Multiple values for `first_key`
|
|
/// In some cases, like the `listfiles` command, there are multiple possible values for `first_key`,
|
|
/// so a vector can be specified.
|
|
/// ```
|
|
/// # use mparsed::{parse_response_vec, File};
|
|
/// let response = "file: A track.flac
|
|
/// size: 123456
|
|
/// Last-Modified: 2019-12-17T08:51:37Z
|
|
/// directory: A directory
|
|
/// Last-Modified: 2015-01-30T14:53:03Z
|
|
/// OK";
|
|
/// let files: Vec<File> = parse_response_vec(response.lines(), &vec!["file: ", "directory: "]).unwrap();
|
|
///
|
|
/// assert_eq!(files.len(), 2);
|
|
/// assert_eq!(files[0].name, String::from("A track.flac"));
|
|
/// assert!(files[1].is_directory());
|
|
/// ```
|
|
pub fn parse_response_vec<'a, I: Iterator<Item = &'a str>, T: de::DeserializeOwned>(input: I, first_keys: &[&str]) -> MpdResult<Vec<T>> {
|
|
input
|
|
.peekable()
|
|
.batching(|it| {
|
|
let mut v = match it.next() {
|
|
// if the response is empty or we’ve reached the end,
|
|
// we receive only the `OK` line.
|
|
Some("OK") => return None,
|
|
// otherwise this is the file attribute and thus the first key-value of this track
|
|
Some(s) => vec![s],
|
|
_ => return None,
|
|
};
|
|
// Only peek here in case we encounter the first key (e.g. `file:`) line which we still need for the next track.
|
|
while let Some(l) = it.peek() {
|
|
if first_keys.iter().any(|s| l.starts_with(s)) {
|
|
return Some(v);
|
|
}
|
|
if l.starts_with("OK") {
|
|
return Some(v);
|
|
}
|
|
v.push(it.next().unwrap());
|
|
}
|
|
None
|
|
})
|
|
.map(|b| parse_response(b.into_iter()))
|
|
.collect()
|
|
}
|
|
|
|
/// Parse the `playlist` command as a vector of filenames.
|
|
///
|
|
/// The playlist index of each item is *not* included because, if needed,
|
|
/// it can easily be added with `.enumerate()`.
|
|
///
|
|
/// Note: The MPD protocol documentation suggests using `playlistinfo` instead,
|
|
/// which returns a superset of this commands output,
|
|
/// but this isn’t deprecated, so if you only need the filenames, it should be all you need.
|
|
pub fn parse_playlist<'a, I: Iterator<Item = &'a str>>(input: I) -> MpdResult<Vec<&'a str>> {
|
|
input
|
|
// `iter.scan((), |(), item| predicate(item))` is equivalent to map_while(predicate),
|
|
// but the latter isn’t stabilized (yet).
|
|
.scan((), |(), s| match s.splitn(2, ' ').next_tuple() {
|
|
Some(("OK", _)) | None => None, // Covers OK with message and without
|
|
Some(("ACK", err)) => Some(Err(Error::from_str(err))),
|
|
Some((_, filename)) => Some(Ok(filename)),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::DateTime;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn track_de_test() {
|
|
let input_str = "file: American Pie.flac
|
|
Last-Modified: 2019-12-16T21:38:54Z
|
|
Album: American Pie
|
|
Artist: Don McLean
|
|
Date: 1979
|
|
Genre: Rock
|
|
Title: American Pie
|
|
Track: 1
|
|
Time: 512
|
|
duration: 512.380
|
|
Pos: 1367
|
|
Id: 1368
|
|
OK";
|
|
let t: Track = parse_response(input_str.lines()).unwrap();
|
|
assert_eq!(
|
|
t,
|
|
Track {
|
|
file: "American Pie.flac".to_string(),
|
|
last_modified: Some(DateTime::parse_from_rfc3339("2019-12-16T21:38:54Z").unwrap()),
|
|
album: Some("American Pie".to_string()),
|
|
artist: Some("Don McLean".to_string()),
|
|
date: Some(1979),
|
|
genre: Some("Rock".to_string()),
|
|
title: Some("American Pie".to_string()),
|
|
track: Some(Position {
|
|
item_position: 1,
|
|
total_items: None,
|
|
}),
|
|
duration: Some(Duration::from_secs_f64(512.380)),
|
|
pos: 1367,
|
|
id: 1368,
|
|
..Track::default()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_field_test() {
|
|
let input_str = r#"file: 06 Symphonie No. 41 en ut majeur, K. 551 _Jupiter_ I. Allegro Vivace.flac
|
|
Last-Modified: 2018-11-11T09:01:54Z
|
|
Album: Symphonies n°40 & n°41
|
|
AlbumArtist: Wolfgang Amadeus Mozart; Royal Philharmonic Orchestra, Jane Glover
|
|
AlbumArtistSort: Mozart, Wolfgang Amadeus; Royal Philharmonic Orchestra, Glover, Jane
|
|
Artist: Wolfgang Amadeus Mozart
|
|
ArtistSort: Mozart, Wolfgang Amadeus
|
|
Composer: Wolfgang Amadeus Mozart
|
|
Date: 2005
|
|
Disc: 1
|
|
Disc: 1
|
|
MUSICBRAINZ_ALBUMARTISTID: b972f589-fb0e-474e-b64a-803b0364fa75
|
|
MUSICBRAINZ_ALBUMID: 688d9252-f897-4ab6-879d-5cb83bb71087
|
|
MUSICBRAINZ_ARTISTID: b972f589-fb0e-474e-b64a-803b0364fa75
|
|
MUSICBRAINZ_RELEASETRACKID: 54a2632f-fa98-3713-bd75-d7effc03d0b1
|
|
MUSICBRAINZ_TRACKID: 2dd10dd8-e8de-4479-a092-9a04c2760fd6
|
|
OriginalDate: 1993
|
|
Title: Symphonie No. 41 en ut majeur, K. 551 "Jupiter": I. Allegro Vivace
|
|
Track: 6
|
|
Track: 6
|
|
Time: 683
|
|
Performer: Royal Philharmonic Orchestra
|
|
duration: 682.840
|
|
Performer: Jane Glover
|
|
Pos: 3439
|
|
Id: 3440
|
|
OK"#;
|
|
let t: Track = parse_response(input_str.lines()).unwrap();
|
|
assert_eq!(
|
|
t,
|
|
Track {
|
|
file: "06 Symphonie No. 41 en ut majeur, K. 551 _Jupiter_ I. Allegro Vivace.flac".to_string(),
|
|
last_modified: Some(DateTime::parse_from_rfc3339("2018-11-11T09:01:54Z").unwrap()),
|
|
album: Some("Symphonies n°40 & n°41".to_string()),
|
|
artist: Some("Wolfgang Amadeus Mozart".to_string()),
|
|
artist_sort: Some("Mozart, Wolfgang Amadeus".to_string()),
|
|
album_artist: Some("Wolfgang Amadeus Mozart; Royal Philharmonic Orchestra, Jane Glover".to_string()),
|
|
album_artist_sort: Some("Mozart, Wolfgang Amadeus; Royal Philharmonic Orchestra, Glover, Jane".to_string()),
|
|
composer: Some("Wolfgang Amadeus Mozart".to_string()),
|
|
date: Some(2005),
|
|
original_date: Some(1993),
|
|
title: Some("Symphonie No. 41 en ut majeur, K. 551 \"Jupiter\": I. Allegro Vivace".to_string()),
|
|
disc: Some(Position {
|
|
item_position: 1,
|
|
total_items: Some(1),
|
|
}),
|
|
track: Some(Position {
|
|
item_position: 6,
|
|
total_items: Some(6),
|
|
}),
|
|
duration: Some(Duration::from_secs_f64(682.840)),
|
|
pos: 3439,
|
|
id: 3440,
|
|
performers: vec!["Royal Philharmonic Orchestra".to_string(), "Jane Glover".to_string()],
|
|
musicbrainz_albumartistid: Some("b972f589-fb0e-474e-b64a-803b0364fa75".to_string()),
|
|
musicbrainz_albumid: Some("688d9252-f897-4ab6-879d-5cb83bb71087".to_string()),
|
|
musicbrainz_artistid: Some("b972f589-fb0e-474e-b64a-803b0364fa75".to_string()),
|
|
musicbrainz_releasetrackid: Some("54a2632f-fa98-3713-bd75-d7effc03d0b1".to_string()),
|
|
musicbrainz_trackid: Some("2dd10dd8-e8de-4479-a092-9a04c2760fd6".to_string()),
|
|
..Track::default()
|
|
}
|
|
);
|
|
}
|
|
#[test]
|
|
fn de_status_test() {
|
|
let input_str = "volume: 74
|
|
repeat: 0
|
|
random: 1
|
|
single: 0
|
|
consume: 0
|
|
playlist: 6
|
|
playlistlength: 5364
|
|
mixrampdb: 0.000000
|
|
state: play
|
|
song: 3833
|
|
songid: 3834
|
|
time: 70:164
|
|
elapsed: 69.642
|
|
bitrate: 702
|
|
duration: 163.760
|
|
audio: 44100:16:2
|
|
nextsong: 4036
|
|
nextsongid: 4037
|
|
OK";
|
|
let s: Status = parse_response(input_str.lines()).unwrap();
|
|
assert_eq!(
|
|
s,
|
|
Status {
|
|
volume: Some(74),
|
|
random: true,
|
|
playlist: 6,
|
|
playlistlength: 5364,
|
|
mixrampdb: 0.0,
|
|
state: State::Play,
|
|
song: Some(3833),
|
|
songid: Some(3834),
|
|
elapsed: Some(Duration::from_secs_f64(69.642)),
|
|
bitrate: Some(702),
|
|
duration: Some(Duration::from_secs_f64(163.760)),
|
|
audio: Some(String::from("44100:16:2")),
|
|
nextsong: Some(4036),
|
|
nextsongid: Some(4037),
|
|
..Status::default()
|
|
}
|
|
);
|
|
}
|
|
#[test]
|
|
fn de_playlistinfo_test() {
|
|
let input_str = "file: 137 A New World.mp3
|
|
Last-Modified: 2018-03-07T13:11:43Z
|
|
Artist: Arata Iiyoshi
|
|
Title: A New World
|
|
Album: Pokemon Mystery Dungeon: Explorers of Sky
|
|
duration: 225.802
|
|
Pos: 1000
|
|
Id: 6365
|
|
file: 139 Thoughts For Friends.mp3
|
|
Last-Modified: 2018-03-07T13:11:43Z
|
|
Artist: Arata Iiyoshi
|
|
Title: Thoughts For Friends
|
|
Album: Pokemon Mystery Dungeon: Explorers of Sky
|
|
duration: 66.560
|
|
Pos: 1001
|
|
Id: 6366
|
|
file: 140 A Message On the Wind.mp3
|
|
Last-Modified: 2018-03-07T13:11:43Z
|
|
Artist: Arata Iiyoshi
|
|
Title: A Message On the Wind
|
|
Album: Pokemon Mystery Dungeon: Explorers of Sky
|
|
duration: 50.155
|
|
Pos: 1002
|
|
Id: 6367
|
|
OK";
|
|
let queue = parse_response_vec(input_str.lines(), &vec!["file: "]);
|
|
let first_track = Track {
|
|
file: "137 A New World.mp3".into(),
|
|
title: Some("A New World".into()),
|
|
artist: Some("Arata Iiyoshi".into()),
|
|
album: Some("Pokemon Mystery Dungeon: Explorers of Sky".into()),
|
|
last_modified: Some(DateTime::parse_from_rfc3339("2018-03-07T13:11:43Z").unwrap()),
|
|
duration: Some(Duration::from_secs_f64(225.802)),
|
|
pos: 1000,
|
|
id: 6365,
|
|
..Track::default()
|
|
};
|
|
assert_eq!(
|
|
queue,
|
|
Ok(vec![
|
|
first_track.clone(),
|
|
Track {
|
|
file: "139 Thoughts For Friends.mp3".into(),
|
|
title: Some("Thoughts For Friends".into()),
|
|
duration: Some(Duration::from_secs_f64(66.56)),
|
|
pos: 1001,
|
|
id: 6366,
|
|
..first_track.clone()
|
|
},
|
|
Track {
|
|
file: "140 A Message On the Wind.mp3".into(),
|
|
title: Some("A Message On the Wind".into()),
|
|
duration: Some(Duration::from_secs_f64(50.155)),
|
|
pos: 1002,
|
|
id: 6367,
|
|
..first_track
|
|
},
|
|
])
|
|
);
|
|
|
|
let queue = parse_response_vec("OK".lines(), &vec!["file: "]);
|
|
assert_eq!(queue, Ok(Vec::<Track>::new()));
|
|
}
|
|
|
|
#[test]
|
|
fn de_stats_test() {
|
|
let input_str = "uptime: 23691
|
|
playtime: 11288
|
|
artists: 2841
|
|
albums: 2455
|
|
songs: 40322
|
|
db_playtime: 11620284
|
|
db_update: 1588433046
|
|
OK";
|
|
let s: Stats = parse_response(input_str.lines()).unwrap();
|
|
assert_eq!(
|
|
s,
|
|
Stats {
|
|
uptime: Duration::from_secs(23691),
|
|
playtime: Duration::from_secs(11288),
|
|
artists: 2841,
|
|
albums: 2455,
|
|
songs: 40322,
|
|
db_playtime: Duration::from_secs(11620284),
|
|
db_update: 1588433046,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn de_unit_response_test() {
|
|
let success = "OK";
|
|
let r: Result<UnitResponse, _> = parse_response(success.lines());
|
|
assert_eq!(r, Ok(UnitResponse {}));
|
|
|
|
let failure = r#"ACK [2@0] {consume} wrong number of arguments for "consume""#;
|
|
let r: Result<UnitResponse, _> = parse_response(failure.lines());
|
|
assert_eq!(
|
|
r,
|
|
Err(error::Error::from_str(r#"[2@0] {consume} wrong number of arguments for "consume""#))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_playlist_test() {
|
|
let input = "0:file: Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/01 Opening Screen.flac
|
|
1:file: Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/02 Wolfstack Lights.flac
|
|
2:file: Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/03 Submergio Viol.flac
|
|
3:file: Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/08 Dark Sailing.flac
|
|
4:file: Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/17 The Sea Does Not Forgive.flac
|
|
5:file: Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/18 Hope Is an Anchor.flac
|
|
OK";
|
|
|
|
match parse_playlist(input.lines()) {
|
|
Err(_) => panic!("Should not be an error"),
|
|
Ok(tracks) => assert_eq!(
|
|
tracks,
|
|
vec![
|
|
"Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/01 Opening Screen.flac",
|
|
"Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/02 Wolfstack Lights.flac",
|
|
"Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/03 Submergio Viol.flac",
|
|
"Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/08 Dark Sailing.flac",
|
|
"Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/17 The Sea Does Not Forgive.flac",
|
|
"Brent Barkman & Maribeth Solomon/Sunless Sea (2015)/18 Hope Is an Anchor.flac",
|
|
]
|
|
),
|
|
}
|
|
|
|
let input = "ACK [] {playlistinfo} something went wrong";
|
|
match parse_playlist(input.lines()) {
|
|
Ok(_) => panic!("Should have failed"),
|
|
Err(e) => assert_eq!(e.message, "[] {playlistinfo} something went wrong"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn file_list_test() {
|
|
let input = "file: 11 奏(かなで)(original スキマスイッチ).flac
|
|
size: 18948052
|
|
Last-Modified: 2019-12-17T08:51:37Z
|
|
directory: Scans
|
|
Last-Modified: 2015-01-30T14:53:03Z
|
|
file: 15 風ハ旅スル(スマートフォンゲーム「風パズル 黒猫と白猫の夢見た世界」テーマ曲).flac
|
|
size: 13058417
|
|
Last-Modified: 2019-12-17T08:51:41Z
|
|
OK";
|
|
let parsed: Vec<File> = parse_response_vec(input.lines(), &vec!["file: ", "directory: "]).unwrap();
|
|
assert_eq!(
|
|
parsed,
|
|
vec![
|
|
File {
|
|
name: "11 奏(かなで)(original スキマスイッチ).flac".into(),
|
|
last_modified: DateTime::parse_from_rfc3339("2019-12-17T08:51:37Z").unwrap(),
|
|
size: 18948052
|
|
},
|
|
File {
|
|
name: "Scans".into(),
|
|
last_modified: DateTime::parse_from_rfc3339("2015-01-30T14:53:03Z").unwrap(),
|
|
size: -1
|
|
},
|
|
File {
|
|
name: "15 風ハ旅スル(スマートフォンゲーム「風パズル 黒猫と白猫の夢見た世界」テーマ曲).flac".into(),
|
|
last_modified: DateTime::parse_from_rfc3339("2019-12-17T08:51:41Z").unwrap(),
|
|
size: 13058417
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(
|
|
parsed.iter().map(|f| f.is_directory()).collect::<Vec<_>>(),
|
|
vec![false, true, false],
|
|
);
|
|
}
|
|
}
|