Krolyxon
Commit 1dcac38

rmptuisrcmpdtypes.rs

425 lines · 12.4 KB · Rust

1//! Typed models built from MPD responses.
2
3use std::{path::Path, str::FromStr, time::Duration};
4
5use super::protocol::{Pair, split_groups};
6
7/// A song as reported by MPD (library entry or queue item).
8#[derive(Clone, Debug, Default, PartialEq)]
9pub struct Song {
10 /// Library-relative path or URL.
11 pub file: String,
12 /// Queue id, present for queue items only.
13 pub id: Option<u32>,
14 /// Queue position, present for queue items only.
15 pub pos: Option<u32>,
16 pub duration: Option<Duration>,
17 /// Tag values in server order. A tag may occur multiple times.
18 pub tags: Vec<(String, String)>,
19}
20
21impl Song {
22 pub fn from_pairs(pairs: &[Pair]) -> Self {
23 let mut song = Self::default();
24 for (key, value) in pairs {
25 match key.as_str() {
26 "file" => song.file.clone_from(value),
27 "Id" => song.id = value.parse().ok(),
28 "Pos" => song.pos = value.parse().ok(),
29 "duration" => song.duration = parse_seconds(value),
30 // Older servers only report the integral `Time`.
31 "Time" if song.duration.is_none() => song.duration = parse_seconds(value),
32 "Last-Modified" | "Added" | "Format" | "Time" | "Range" | "Prio" => {}
33 _ => song.tags.push((key.clone(), value.clone())),
34 }
35 }
36 song
37 }
38
39 /// The first value of `tag` (case-insensitive).
40 pub fn tag(&self, tag: &str) -> Option<&str> {
41 self.tags
42 .iter()
43 .find(|(key, _)| key.eq_ignore_ascii_case(tag))
44 .map(|(_, value)| value.as_str())
45 }
46
47 pub fn title(&self) -> Option<&str> {
48 self.tag("Title")
49 }
50
51 pub fn artist(&self) -> Option<&str> {
52 self.tag("Artist")
53 }
54
55 pub fn album(&self) -> Option<&str> {
56 self.tag("Album")
57 }
58
59 pub fn album_artist(&self) -> Option<&str> {
60 self.tag("AlbumArtist").or_else(|| self.artist())
61 }
62
63 /// The file name without its directory.
64 pub fn file_name(&self) -> &str {
65 self.file.rsplit('/').next().unwrap_or(&self.file)
66 }
67
68 /// The title, falling back to the file name without extension.
69 pub fn display_title(&self) -> &str {
70 self.title().unwrap_or_else(|| {
71 let name = self.file_name();
72 Path::new(name)
73 .file_stem()
74 .and_then(|stem| stem.to_str())
75 .unwrap_or(name)
76 })
77 }
78
79 /// Track number without a `/total` suffix.
80 pub fn track(&self) -> Option<&str> {
81 self.tag("Track").map(|t| t.split('/').next().unwrap_or(t))
82 }
83
84 /// Sort key for album order: disc, then track number.
85 pub fn disc_track(&self) -> (u32, u32) {
86 let number = |tag: &str| {
87 self.tag(tag)
88 .and_then(|t| t.split('/').next())
89 .and_then(|t| t.trim().parse().ok())
90 .unwrap_or(0)
91 };
92 (number("Disc"), number("Track"))
93 }
94}
95
96pub fn parse_songs(pairs: &[Pair]) -> Vec<Song> {
97 split_groups(pairs, &["file"])
98 .into_iter()
99 .map(Song::from_pairs)
100 .collect()
101}
102
103/// An entry of `lsinfo`.
104#[derive(Clone, Debug, PartialEq)]
105pub enum DirEntry {
106 Directory(String),
107 Song(Song),
108 Playlist(String),
109}
110
111impl DirEntry {
112 /// The library path of the entry.
113 pub fn path(&self) -> &str {
114 match self {
115 Self::Directory(path) | Self::Playlist(path) => path,
116 Self::Song(song) => &song.file,
117 }
118 }
119
120 /// The last path component, as shown in the browser.
121 pub fn name(&self) -> &str {
122 let path = self.path();
123 path.rsplit('/').next().unwrap_or(path)
124 }
125}
126
127pub fn parse_entries(pairs: &[Pair]) -> Vec<DirEntry> {
128 split_groups(pairs, &["file", "directory", "playlist"])
129 .into_iter()
130 .filter_map(|group| {
131 let (key, value) = group.first()?;
132 Some(match key.as_str() {
133 "directory" => DirEntry::Directory(value.clone()),
134 "playlist" => DirEntry::Playlist(value.clone()),
135 _ => DirEntry::Song(Song::from_pairs(group)),
136 })
137 })
138 .collect()
139}
140
141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
142pub enum PlayState {
143 #[default]
144 Stop,
145 Play,
146 Pause,
147}
148
149/// The tri-state used by `single` and `consume`.
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151pub enum OnOff {
152 #[default]
153 Off,
154 On,
155 Oneshot,
156}
157
158impl OnOff {
159 fn parse(value: &str) -> Self {
160 match value {
161 "1" => Self::On,
162 "oneshot" => Self::Oneshot,
163 _ => Self::Off,
164 }
165 }
166
167 pub fn is_on(self) -> bool {
168 self != Self::Off
169 }
170}
171
172#[derive(Clone, Debug, Default, PartialEq)]
173pub struct Status {
174 /// `None` when the server has no mixer.
175 pub volume: Option<u8>,
176 pub repeat: bool,
177 pub random: bool,
178 pub single: OnOff,
179 pub consume: OnOff,
180 /// The queue version, bumped on every queue change.
181 pub playlist_version: u32,
182 pub playlist_length: u32,
183 pub state: PlayState,
184 /// Queue position of the current song.
185 pub song: Option<u32>,
186 pub song_id: Option<u32>,
187 pub elapsed: Duration,
188 pub duration: Option<Duration>,
189 /// Kilobits per second.
190 pub bitrate: Option<u32>,
191 /// `samplerate:bits:channels`.
192 pub audio: Option<String>,
193 pub updating_db: bool,
194 pub error: Option<String>,
195}
196
197impl Status {
198 pub fn from_pairs(pairs: &[Pair]) -> Self {
199 let mut status = Self::default();
200 for (key, value) in pairs {
201 match key.as_str() {
202 "volume" => status.volume = value.parse().ok(),
203 "repeat" => status.repeat = value == "1",
204 "random" => status.random = value == "1",
205 "single" => status.single = OnOff::parse(value),
206 "consume" => status.consume = OnOff::parse(value),
207 "playlist" => status.playlist_version = parse_or_default(value),
208 "playlistlength" => status.playlist_length = parse_or_default(value),
209 "state" => {
210 status.state = match value.as_str() {
211 "play" => PlayState::Play,
212 "pause" => PlayState::Pause,
213 _ => PlayState::Stop,
214 }
215 }
216 "song" => status.song = value.parse().ok(),
217 "songid" => status.song_id = value.parse().ok(),
218 "elapsed" => status.elapsed = parse_seconds(value).unwrap_or_default(),
219 "duration" => status.duration = parse_seconds(value),
220 "bitrate" => status.bitrate = value.parse().ok().filter(|&b| b > 0),
221 "audio" => status.audio = Some(value.clone()),
222 "updating_db" => status.updating_db = true,
223 "error" => status.error = Some(value.clone()),
224 _ => {}
225 }
226 }
227 status
228 }
229}
230
231/// A stored playlist.
232#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct PlaylistInfo {
234 pub name: String,
235}
236
237pub fn parse_playlists(pairs: &[Pair]) -> Vec<PlaylistInfo> {
238 let mut playlists: Vec<PlaylistInfo> = pairs
239 .iter()
240 .filter(|(key, _)| key == "playlist")
241 .map(|(_, name)| PlaylistInfo { name: name.clone() })
242 .collect();
243 playlists.sort_by_key(|p| p.name.to_lowercase());
244 playlists
245}
246
247/// An album with its (optional) release date, from `list album group date`.
248#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
249pub struct Album {
250 pub date: String,
251 pub name: String,
252}
253
254pub fn parse_albums(pairs: &[Pair]) -> Vec<Album> {
255 let mut albums = Vec::new();
256 let mut date = String::new();
257 for (key, value) in pairs {
258 if key.eq_ignore_ascii_case("Date") {
259 date.clone_from(value);
260 } else if key.eq_ignore_ascii_case("Album") {
261 albums.push(Album {
262 date: date.clone(),
263 name: value.clone(),
264 });
265 }
266 }
267 albums.sort();
268 albums.dedup();
269 albums
270}
271
272/// Values of a single tag from `list <tag>`.
273pub fn parse_tag_values(pairs: &[Pair], tag: &str) -> Vec<String> {
274 let mut values: Vec<String> = pairs
275 .iter()
276 .filter(|(key, _)| key.eq_ignore_ascii_case(tag))
277 .map(|(_, value)| value.clone())
278 .collect();
279 values.sort_by_cached_key(|v| v.to_lowercase());
280 values.dedup();
281 values
282}
283
284/// An MPD idle subsystem.
285#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
286pub enum Subsystem {
287 Database,
288 Update,
289 StoredPlaylist,
290 Playlist,
291 Player,
292 Mixer,
293 Output,
294 Options,
295 Other,
296}
297
298impl FromStr for Subsystem {
299 type Err = std::convert::Infallible;
300
301 fn from_str(s: &str) -> Result<Self, Self::Err> {
302 Ok(match s {
303 "database" => Self::Database,
304 "update" => Self::Update,
305 "stored_playlist" => Self::StoredPlaylist,
306 "playlist" => Self::Playlist,
307 "player" => Self::Player,
308 "mixer" => Self::Mixer,
309 "output" => Self::Output,
310 "options" => Self::Options,
311 _ => Self::Other,
312 })
313 }
314}
315
316pub fn parse_subsystems(pairs: &[Pair]) -> Vec<Subsystem> {
317 pairs
318 .iter()
319 .filter(|(key, _)| key == "changed")
320 .filter_map(|(_, value)| value.parse().ok())
321 .collect()
322}
323
324fn parse_seconds(value: &str) -> Option<Duration> {
325 value
326 .parse::<f64>()
327 .ok()
328 .filter(|s| s.is_finite() && *s >= 0.0)
329 .map(Duration::from_secs_f64)
330}
331
332fn parse_or_default<T: FromStr + Default>(value: &str) -> T {
333 value.parse().unwrap_or_default()
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 fn pairs(items: &[(&str, &str)]) -> Vec<Pair> {
341 items
342 .iter()
343 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
344 .collect()
345 }
346
347 #[test]
348 fn parses_queue_songs() {
349 let songs = parse_songs(&pairs(&[
350 ("file", "a/01 - One.flac"),
351 ("Title", "One"),
352 ("Artist", "X"),
353 ("Track", "1/10"),
354 ("duration", "61.5"),
355 ("Pos", "0"),
356 ("Id", "7"),
357 ("file", "b/two.mp3"),
358 ("Time", "30"),
359 ]));
360 assert_eq!(songs.len(), 2);
361 assert_eq!(songs[0].id, Some(7));
362 assert_eq!(songs[0].track(), Some("1"));
363 assert_eq!(songs[0].duration, Some(Duration::from_millis(61_500)));
364 assert_eq!(songs[1].display_title(), "two");
365 assert_eq!(songs[1].duration, Some(Duration::from_secs(30)));
366 assert_eq!(songs[1].artist(), None);
367 }
368
369 #[test]
370 fn parses_lsinfo_entries() {
371 let entries = parse_entries(&pairs(&[
372 ("directory", "music/rock"),
373 ("Last-Modified", "2024"),
374 ("file", "music/song.mp3"),
375 ("Title", "S"),
376 ("playlist", "mix"),
377 ]));
378 assert_eq!(entries.len(), 3);
379 assert_eq!(entries[0], DirEntry::Directory("music/rock".into()));
380 assert_eq!(entries[0].name(), "rock");
381 assert!(matches!(&entries[1], DirEntry::Song(s) if s.title() == Some("S")));
382 assert_eq!(entries[2], DirEntry::Playlist("mix".into()));
383 }
384
385 #[test]
386 fn parses_status() {
387 let status = Status::from_pairs(&pairs(&[
388 ("volume", "-1"),
389 ("repeat", "1"),
390 ("single", "oneshot"),
391 ("playlist", "12"),
392 ("playlistlength", "3"),
393 ("state", "pause"),
394 ("songid", "4"),
395 ("elapsed", "1.250"),
396 ("bitrate", "0"),
397 ]));
398 assert_eq!(status.volume, None);
399 assert!(status.repeat && !status.random);
400 assert_eq!(status.single, OnOff::Oneshot);
401 assert_eq!(status.playlist_version, 12);
402 assert_eq!(status.state, PlayState::Pause);
403 assert_eq!(status.elapsed, Duration::from_millis(1250));
404 assert_eq!(status.bitrate, None);
405 }
406
407 #[test]
408 fn parses_grouped_albums() {
409 let albums = parse_albums(&pairs(&[
410 ("Date", "2016"),
411 ("Album", "B"),
412 ("Date", "2001"),
413 ("Album", "A"),
414 ("Album", "C"),
415 ]));
416 let names: Vec<_> = albums.iter().map(|a| a.name.as_str()).collect();
417 assert_eq!(names, ["A", "C", "B"]);
418 }
419
420 #[test]
421 fn parses_subsystems() {
422 let subs = parse_subsystems(&pairs(&[("changed", "player"), ("changed", "mixer")]));
423 assert_eq!(subs, [Subsystem::Player, Subsystem::Mixer]);
424 }
425}