rmptui222ab2f
Play the selected song when loading a playlist with missing entries
MPD's load silently skips entries it cannot resolve, so the playlist index no longer matched the queue offset and a later song played. Match the playlist's entries against the songs load actually appended instead.
+75 −3
1 file changed
- M src/mpd/worker.rs +75 −3
| @@ -15,7 +15,7 @@ use super::{ | |||
| 15 | 15 | commands, | |
| 16 | 16 | connection::{Address, Connection}, | |
| 17 | 17 | error::{MpdError, MpdResult}, | |
| 18 | − | protocol::Command, | |
| 18 | + | protocol::{Command, Pair}, | |
| 19 | 19 | types::{ | |
| 20 | 20 | Album, DirEntry, PlaylistInfo, Song, Status, Subsystem, parse_albums, parse_entries, | |
| 21 | 21 | parse_playlists, parse_songs, parse_subsystems, parse_tag_values, | |
| @@ -296,11 +296,59 @@ async fn execute(conn: &mut Connection, request: &Request) -> MpdResult<Option<R | |||
| 296 | 296 | } | |
| 297 | 297 | Request::LoadAndPlay { name, offset } => { | |
| 298 | 298 | let start = queue_length(conn).await?; | |
| 299 | − | conn.execute_all(&[commands::load(name), commands::play_pos(start + offset)]) | |
| 299 | + | let entries = conn.execute(&Command::new("listplaylist").arg(name)).await?; | |
| 300 | + | conn.execute(&commands::load(name)).await?; | |
| 301 | + | // `load` silently skips entries the server cannot resolve, so the | |
| 302 | + | // playlist index is not necessarily the queue offset. | |
| 303 | + | let added = conn | |
| 304 | + | .execute(&Command::new("playlistinfo").arg(format!("{start}:"))) | |
| 305 | + | .await?; | |
| 306 | + | let entries: Vec<&str> = files(&entries).collect(); | |
| 307 | + | let added: Vec<&str> = files(&added).collect(); | |
| 308 | + | let Some(pos) = queued_offset(&entries, &added, *offset as usize) else { | |
| 309 | + | return Ok(None); | |
| 310 | + | }; | |
| 311 | + | conn.execute(&commands::play_pos(start + pos as u32)) | |
| 300 | 312 | .await | |
| 301 | − | .map(|()| None) | |
| 313 | + | .map(|_| None) | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | fn files(pairs: &[Pair]) -> impl Iterator<Item = &str> { | |
| 319 | + | pairs | |
| 320 | + | .iter() | |
| 321 | + | .filter(|(key, _)| key == "file") | |
| 322 | + | .map(|(_, value)| value.as_str()) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// Where playlist entry `index` ended up among the songs `load` appended, | |
| 326 | + | /// matching entries in order and skipping the ones that were dropped. | |
| 327 | + | fn queued_offset(entries: &[&str], added: &[&str], index: usize) -> Option<usize> { | |
| 328 | + | if entries.len() == added.len() { | |
| 329 | + | return (index < added.len()).then_some(index); | |
| 330 | + | } | |
| 331 | + | let mut pos = 0; | |
| 332 | + | for (i, entry) in entries.iter().enumerate() { | |
| 333 | + | let matched = added.get(pos).is_some_and(|song| same_song(entry, song)); | |
| 334 | + | if i == index { | |
| 335 | + | // The entry was skipped: play whatever followed it instead. | |
| 336 | + | return (pos < added.len()).then_some(pos); | |
| 337 | + | } | |
| 338 | + | if matched { | |
| 339 | + | pos += 1; | |
| 302 | 340 | } | |
| 303 | 341 | } | |
| 342 | + | None | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// Playlists may store absolute paths, which `load` turns into paths | |
| 346 | + | /// relative to the music directory. | |
| 347 | + | fn same_song(entry: &str, queued: &str) -> bool { | |
| 348 | + | entry == queued | |
| 349 | + | || entry | |
| 350 | + | .strip_suffix(queued) | |
| 351 | + | .is_some_and(|prefix| prefix.ends_with('/')) | |
| 304 | 352 | } | |
| 305 | 353 | ||
| 306 | 354 | async fn queue_length(conn: &mut Connection) -> MpdResult<u32> { | |
| @@ -401,3 +449,27 @@ async fn run_query(conn: &mut Connection, query: &Query) -> MpdResult<Reply> { | |||
| 401 | 449 | } | |
| 402 | 450 | }) | |
| 403 | 451 | } | |
| 452 | + | ||
| 453 | + | #[cfg(test)] | |
| 454 | + | mod tests { | |
| 455 | + | use super::queued_offset; | |
| 456 | + | ||
| 457 | + | #[test] | |
| 458 | + | fn queued_offset_skips_dropped_entries() { | |
| 459 | + | let entries = ["gone.mp3", "a.mp3", "b.mp3", "missing.mp3", "c.mp3"]; | |
| 460 | + | let added = ["a.mp3", "b.mp3", "c.mp3"]; | |
| 461 | + | assert_eq!(queued_offset(&entries, &added, 1), Some(0)); | |
| 462 | + | assert_eq!(queued_offset(&entries, &added, 2), Some(1)); | |
| 463 | + | assert_eq!(queued_offset(&entries, &added, 4), Some(2)); | |
| 464 | + | // A dropped entry plays the song after it. | |
| 465 | + | assert_eq!(queued_offset(&entries, &added, 0), Some(0)); | |
| 466 | + | assert_eq!(queued_offset(&entries, &added, 3), Some(2)); | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | #[test] | |
| 470 | + | fn queued_offset_matches_absolute_paths() { | |
| 471 | + | let entries = ["/music/x.mp3", "gone.mp3", "/music/y.mp3"]; | |
| 472 | + | let added = ["x.mp3", "y.mp3"]; | |
| 473 | + | assert_eq!(queued_offset(&entries, &added, 2), Some(1)); | |
| 474 | + | } | |
| 475 | + | } | |