1use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13
14use crossbeam_channel::Sender;
15
16use crate::audio::viz::VizSnapshot;
17use crate::db::connection::Database;
18use crate::db::queries;
19use crate::player::commands::PlayerCommand;
20use crate::player::state::{
21 PlaybackState, PlaylistItem, QueueItemId, QueueMode, SharedPlayerState,
22};
23use crate::remote::acks::{AckOutcome, Pending};
24use crate::remote::devices;
25use crate::remote::link::{
26 CommandSource, LinkCommand, LinkIdentity, LinkQueueEntry, LinkState, Local,
27};
28
29pub fn serve(
38 identity: LinkIdentity,
39 state: Arc<SharedPlayerState>,
40 viz: Arc<VizSnapshot>,
41 tx: Sender<PlayerCommand>,
42 library_changed: fn(),
43 here: bool,
44) {
45 let playhead = state.clone();
46 crate::remote::levels::feed().provide(viz, move || playhead.position_ms());
47 crate::remote::dsp_sync::set_device_name(Some(identity.name.clone()));
48
49 let (commands, inbox) =
50 crossbeam_channel::unbounded::<(LinkCommand, CommandSource, Option<Pending>)>();
51 {
52 let (state, tx) = (state.clone(), tx);
53 std::thread::Builder::new()
54 .name("koan-link-commands".into())
55 .spawn(move || {
56 for (cmd, source, pending) in inbox {
57 let outcome = match apply(cmd, source, &tx, &state, &library_changed) {
58 Ok(()) => AckOutcome::Done,
59 Err(error) => {
60 log::warn!("link: {error}");
61 AckOutcome::Failed { error }
62 }
63 };
64 if let Some(pending) = pending {
65 pending.finish(outcome);
66 }
67 }
68 })
69 .expect("failed to spawn the link command thread");
70 }
71
72 let held = parking_lot::Mutex::new(None::<(u64, Vec<LinkQueueEntry>)>);
75 devices::start(Local {
76 identity,
77 on_command: Arc::new(move |cmd, source, pending| {
78 let _ = commands.send((cmd, source, pending));
79 }),
80 state: Arc::new(move || {
81 let item = state.cursor().and_then(|c| state.get_item(c));
82 let version = state.playlist_version();
83 let mut held = held.lock();
84 if held.as_ref().is_none_or(|(v, _)| *v != version) {
85 *held = Some((version, link_queue(&state)));
86 }
87 LinkState {
88 playing: state.playback_state() == PlaybackState::Playing,
89 title: item.as_ref().map(|i| i.title.clone()),
90 artist: item.as_ref().map(|i| i.artist.clone()),
91 album: item.as_ref().map(|i| i.album.clone()),
92 position_ms: state.position_ms(),
93 duration_ms: state.duration_ms(),
94 queue: held.as_ref().map(|(_, q)| q.clone()).unwrap_or_default(),
95 outputs: Some(crate::remote::outputs::local(&state)),
96 shuffle: state.play_mode().shuffle,
97 repeat: state.play_mode().repeat,
98 sleep: state.sleep(),
99 sleep_fading: state.sleep_fading(),
100 }
101 }),
102 });
103 if here {
104 devices::set_target(None);
105 }
106}
107
108pub fn stop() {
112 crate::remote::nearby::suspend();
113 crate::remote::link::hang_up();
114}
115
116pub fn link_queue(state: &SharedPlayerState) -> Vec<LinkQueueEntry> {
119 const LINK_QUEUE_MAX: usize = 300;
120 let (window, cursor) = state.playlist_window(20, LINK_QUEUE_MAX);
121 let remote = crate::db::pool::shared()
122 .get()
123 .map(|db| remote_ids(&db, &window))
124 .unwrap_or_default();
125 window
126 .iter()
127 .map(|i| LinkQueueEntry {
128 id: Some(i.id.0.to_string()),
129 track_id: i.db_id.and_then(|id| remote.get(&id).cloned()),
130 title: i.title.clone(),
131 artist: i.artist.clone(),
132 album: i.album.clone(),
133 duration_ms: i.duration_ms.unwrap_or(0),
134 current: Some(i.id) == cursor,
135 })
136 .collect()
137}
138
139fn remote_ids(db: &Database, items: &[PlaylistItem]) -> HashMap<i64, String> {
141 let ids: Vec<i64> = items.iter().filter_map(|i| i.db_id).collect();
142 queries::tracks_by_ids(&db.conn, &ids)
143 .unwrap_or_default()
144 .into_iter()
145 .filter_map(|t| t.remote_id.map(|r| (t.id, r)))
146 .collect()
147}
148
149fn parse_qid(s: &str) -> Result<QueueItemId, String> {
150 uuid::Uuid::parse_str(s)
151 .map(QueueItemId)
152 .map_err(|_| format!("not a queue item id: {s}"))
153}
154
155fn parse_qids(ids: &[String]) -> Result<Vec<QueueItemId>, String> {
156 ids.iter().map(|s| parse_qid(s)).collect()
157}
158
159pub fn apply(
164 cmd: LinkCommand,
165 source: CommandSource,
166 tx: &Sender<PlayerCommand>,
167 state: &SharedPlayerState,
168 library_changed: &dyn Fn(),
169) -> Result<(), String> {
170 let send = |cmd: PlayerCommand| {
171 tx.send(cmd)
172 .map_err(|_| "the player has stopped".to_string())
173 };
174 let may_sync = source == CommandSource::Account;
177 let db = || {
178 crate::db::pool::shared()
179 .get()
180 .map_err(|e| format!("library: {e}"))
181 };
182 let resolve = |db: &Database, ids: &[String]| {
183 let (found, synced) = crate::remote::link::resolve_each(db, ids, may_sync);
184 if synced {
185 library_changed();
186 }
187 found
188 };
189 let items_of = |db: &Database, ids: &[i64]| {
190 let rows = queries::tracks_by_ids(&db.conn, ids).unwrap_or_default();
191 crate::helpers::playlist_items_for_tracks(db, &rows)
192 };
193 let items = |track_ids: &[String]| -> Result<Vec<PlaylistItem>, String> {
194 let db = db()?;
195 let ids: Vec<i64> = resolve(&db, track_ids).into_iter().flatten().collect();
196 Ok(items_of(&db, &ids))
197 };
198 match cmd {
199 LinkCommand::Play {
200 track_ids,
201 start_at,
202 position_ms,
203 paused,
204 handoff,
205 mode,
206 } => {
207 if handoff && devices::target().is_some() {
210 log::info!("link: handed the music; taking control back");
211 devices::set_target(None);
212 }
213 let db = db()?;
214 let found = resolve(&db, &track_ids);
215 let (start, position_ms) = start_among(&found, start_at as usize, position_ms);
216 let ids: Vec<i64> = found.into_iter().flatten().collect();
217 let items = items_of(&db, &ids);
218 if items.is_empty() {
219 return Err(format!(
220 "none of {} tracks are in the library",
221 track_ids.len()
222 ));
223 }
224 send(PlayerCommand::ReplacePlaylist {
225 start: start.min(items.len() - 1),
226 items,
227 position_ms,
228 play: !paused,
229 mode,
230 })
231 }
232 LinkCommand::Enqueue { track_ids, play } => {
233 let items = items(&track_ids)?;
234 if items.is_empty() {
235 return Ok(());
236 }
237 if play || state.is_idle() {
238 send(PlayerCommand::AddAndPlay(items))
239 } else {
240 send(PlayerCommand::AddToPlaylist(items))
241 }
242 }
243 LinkCommand::PlayNext { track_ids } => {
244 let items = items(&track_ids)?;
245 if items.is_empty() {
246 return Ok(());
247 }
248 match state.cursor() {
249 Some(after) => send(PlayerCommand::InsertInPlaylist { items, after }),
250 None => send(PlayerCommand::AddToPlaylist(items)),
251 }
252 }
253 LinkCommand::Insert { track_ids, after } => {
254 let after = parse_qid(&after)?;
255 let items = items(&track_ids)?;
256 if items.is_empty() {
257 return Ok(());
258 }
259 send(PlayerCommand::InsertInPlaylist { items, after })
260 }
261 LinkCommand::JumpTo { track_id } => {
262 let db = db()?;
263 let Some(local) = resolve(&db, std::slice::from_ref(&track_id))
264 .first()
265 .copied()
266 .flatten()
267 else {
268 return Err(format!("track {track_id} is not in the library"));
269 };
270 let (queued, cursor) = state.snapshot_playlist();
271 if let Some(item) = queued.iter().find(|i| i.db_id == Some(local)) {
272 return send(PlayerCommand::Play(item.id));
273 }
274 let Some(item) = items_of(&db, &[local]).pop() else {
275 return Err(format!("track {track_id} is not in the library"));
276 };
277 let id = item.id;
278 match cursor {
279 Some(after) => send(PlayerCommand::InsertInPlaylist {
280 items: vec![item],
281 after,
282 })?,
283 None => send(PlayerCommand::AddToPlaylist(vec![item]))?,
284 }
285 send(PlayerCommand::Play(id))
286 }
287 LinkCommand::Remove { track_ids } => {
288 let db = db()?;
289 let found: HashSet<i64> = resolve(&db, &track_ids).into_iter().flatten().collect();
290 let (queued, _) = state.snapshot_playlist();
291 let gone: Vec<QueueItemId> = queued
292 .iter()
293 .filter(|i| i.db_id.is_some_and(|id| found.contains(&id)))
294 .map(|i| i.id)
295 .collect();
296 if gone.is_empty() {
297 return Ok(());
298 }
299 send(PlayerCommand::RemoveFromPlaylistBatch(gone))
300 }
301 LinkCommand::PlayItem { id } => {
302 let id = parse_qid(&id)?;
303 match state.get_item(id) {
304 Some(_) => send(PlayerCommand::Play(id)),
305 None => Ok(()),
306 }
307 }
308 LinkCommand::RemoveItems { ids } => {
309 send(PlayerCommand::RemoveFromPlaylistBatch(parse_qids(&ids)?))
310 }
311 LinkCommand::MoveItems { ids, target, after } => send(PlayerCommand::MoveItemsInPlaylist {
312 ids: parse_qids(&ids)?,
313 target: parse_qid(&target)?,
314 after,
315 }),
316 LinkCommand::Clear => send(PlayerCommand::ClearPlaylist),
317 LinkCommand::Undo => send(PlayerCommand::Undo),
318 LinkCommand::Redo => send(PlayerCommand::Redo),
319 LinkCommand::Seek { position_ms } => send(PlayerCommand::Seek(position_ms)),
320 LinkCommand::Pause => send(PlayerCommand::Pause),
321 LinkCommand::Resume => send(PlayerCommand::Resume),
322 LinkCommand::Next => send(PlayerCommand::NextTrack),
323 LinkCommand::Previous => send(PlayerCommand::PrevTrack),
324 LinkCommand::Shuffle { on } => send(PlayerCommand::SetShuffle(on)),
325 LinkCommand::Repeat { mode } => send(PlayerCommand::SetRepeat(mode)),
326 LinkCommand::SleepTimer { timer } => send(PlayerCommand::SetSleepTimer(timer)),
327 LinkCommand::HandOff { to } => {
330 let was_playing = state.playback_state() == PlaybackState::Playing;
331 let player = tx.clone();
332 let then: devices::Then = Box::new(move |outcome| {
333 let refused = matches!(
334 outcome,
335 Some(AckOutcome::Refused { .. } | AckOutcome::Failed { .. })
336 );
337 if refused && was_playing {
338 let _ = player.send(PlayerCommand::Resume);
339 }
340 });
341 hand_off(&to, source, tx, state, Some(then)).map(|_| ())
342 }
343 LinkCommand::Shared { command } => {
346 if command.allowed_playback() {
347 apply(*command, CommandSource::Shared, tx, state, library_changed)?;
348 }
349 Ok(())
350 }
351 LinkCommand::SetOutput { output } => {
352 crate::remote::outputs::set(output, crate::upnp::choose(), tx)
353 }
354 LinkCommand::RefreshOutputs => {
355 crate::remote::outputs::refresh_for_controller();
356 Ok(())
357 }
358 LinkCommand::SetRendererVolume { volume } => send(PlayerCommand::SetRendererVolume(volume)),
359 LinkCommand::SetPreset { device, profile } => {
360 crate::remote::outputs::set_preset(&device, profile.as_deref(), tx)
361 }
362 LinkCommand::Evict { track_ids } => {
363 let db = db()?;
364 let ids: Vec<i64> = resolve(&db, &track_ids).into_iter().flatten().collect();
365 let rows = queries::tracks_by_ids(&db.conn, &ids).unwrap_or_default();
366 for path in rows.iter().filter_map(|t| t.cached_path.as_deref()) {
367 let _ = std::fs::remove_file(path);
368 }
369 queries::clear_cached_paths_for(&db.conn, &ids).map_err(|e| e.to_string())?;
370 log::info!("link: evicted {} cached tracks", ids.len());
371 library_changed();
372 Ok(())
373 }
374 LinkCommand::HistoryChanged => {
375 if crate::remote::history::sync(&*db()?).library_synced {
379 library_changed();
380 }
381 Ok(())
382 }
383 LinkCommand::DspProfilesChanged => {
384 if crate::remote::dsp_sync::sync(&*db()?).changed() {
385 library_changed();
387 send(PlayerCommand::ReloadDsp)?;
388 }
389 Ok(())
390 }
391 LinkCommand::Sync { full } => {
392 let walk = if full {
393 crate::helpers::Walk::Always
394 } else {
395 crate::helpers::Walk::IfChanged
396 };
397 crate::remote::link::sync(&*db()?, walk);
398 library_changed();
399 Ok(())
400 }
401 LinkCommand::Devices { .. }
403 | LinkCommand::DeviceKeys { .. }
404 | LinkCommand::Shares { .. }
405 | LinkCommand::Forgotten { .. }
406 | LinkCommand::WatchLevels { .. }
407 | LinkCommand::Levels { .. }
408 | LinkCommand::Acked { .. } => Ok(()),
409 }
410}
411
412fn start_among(found: &[Option<i64>], start_at: usize, position_ms: u64) -> (usize, u64) {
416 let start = found.iter().take(start_at).filter(|f| f.is_some()).count();
417 let kept = found.get(start_at).is_some_and(|f| f.is_some());
418 let remaining = found.iter().skip(start_at).any(|f| f.is_some());
419 match (kept, remaining) {
420 (true, _) => (start, position_ms),
421 (false, true) => (start, 0),
422 (false, false) => (0, 0),
423 }
424}
425
426pub struct HandOff {
428 pub left_out: u32,
430 pub track: String,
432 pub before: Option<LinkState>,
434 pub was_playing: bool,
436}
437
438pub fn hand_off(
443 to: &str,
444 source: CommandSource,
445 tx: &Sender<PlayerCommand>,
446 state: &SharedPlayerState,
447 then: Option<devices::Then>,
448) -> Result<HandOff, String> {
449 let (items, cursor) = state.snapshot_playlist();
450 let remote = crate::db::pool::shared()
451 .get()
452 .map(|db| remote_ids(&db, &items))
453 .unwrap_or_default();
454 let at = cursor
455 .and_then(|c| items.iter().position(|i| i.id == c))
456 .unwrap_or(0);
457 let kept: Vec<(usize, String)> = items
458 .iter()
459 .enumerate()
460 .filter_map(|(n, i)| {
461 i.db_id
462 .and_then(|id| remote.get(&id).cloned())
463 .map(|r| (n, r))
464 })
465 .collect();
466 if kept.is_empty() {
467 return Err(
468 "nothing in the queue is on the server, so the other device cannot play it".into(),
469 );
470 }
471 let start_at = kept.iter().position(|(n, _)| *n >= at).unwrap_or(0);
472 let paused = state.playback_state() != PlaybackState::Playing;
475 let (reply, silent) = crossbeam_channel::bounded(1);
476 tx.send(PlayerCommand::PauseAndReport(reply))
477 .map_err(|_| "the player has stopped".to_string())?;
478 let position_ms = silent
479 .recv_timeout(std::time::Duration::from_secs(1))
480 .unwrap_or_else(|_| state.position_ms());
481 let position_ms = if kept.get(start_at).is_some_and(|(n, _)| *n == at) {
482 position_ms
483 } else {
484 0
485 };
486 let left_out = (items.len() - kept.len()) as u32;
487 let track = kept[start_at].1.clone();
488 let before = devices::last_report(to);
489 let play = LinkCommand::Play {
490 track_ids: kept.into_iter().map(|(_, r)| r).collect(),
491 start_at: start_at as u32,
492 position_ms,
493 paused,
494 handoff: true,
495 mode: QueueMode::Keep,
496 };
497 if let Err(e) = devices::send_for_then(source, to, play, then) {
498 if !paused {
499 let _ = tx.send(PlayerCommand::Resume);
500 }
501 return Err(e);
502 }
503 log::info!("devices: sent the queue to {to}, {left_out} left out");
504 Ok(HandOff {
505 left_out,
506 track,
507 before,
508 was_playing: !paused,
509 })
510}
511
512pub fn control(
516 id: Option<String>,
517 tx: &Sender<PlayerCommand>,
518 state: &SharedPlayerState,
519) -> Result<(), String> {
520 if let Some(id) = &id {
521 devices::choosable(id)?;
522 if state.playback_state() == PlaybackState::Playing {
523 let _ = tx.send(PlayerCommand::Pause);
524 }
525 }
526 devices::set_target(id.clone());
527 if let Some(id) = id {
529 devices::wake(&id);
530 }
531 Ok(())
532}
533
534pub enum Moved {
536 Sent(Box<HandOff>),
538 Asked,
541}
542
543pub fn move_music(
547 to: Option<String>,
548 tx: &Sender<PlayerCommand>,
549 state: &SharedPlayerState,
550 then: Option<devices::Then>,
551) -> Result<Option<Moved>, String> {
552 let from = devices::target();
553 if from == to {
554 return Ok(None);
555 }
556 let moved = match (&from, &to) {
557 (None, Some(to)) => Some(Moved::Sent(Box::new(hand_off(
558 to,
559 CommandSource::Account,
560 tx,
561 state,
562 then,
563 )?))),
564 (Some(from), to) => {
565 let to = match to {
566 Some(to) => to.clone(),
567 None => devices::this_id()
568 .ok_or_else(|| "this device is not set up to be reached".to_string())?,
569 };
570 devices::send_then(from, LinkCommand::HandOff { to }, then)?;
571 Some(Moved::Asked)
572 }
573 (None, None) => None,
574 };
575 devices::set_target(to);
576 Ok(moved)
577}
578
579#[derive(Debug, Clone, PartialEq, Eq)]
581pub enum SendError {
582 Unplayable(String),
584 Missed(String),
586 Stopped,
588}
589
590impl std::fmt::Display for SendError {
591 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
592 match self {
593 Self::Unplayable(m) | Self::Missed(m) => f.write_str(m),
594 Self::Stopped => f.write_str("the player has stopped"),
595 }
596 }
597}
598
599pub fn send(cmd: PlayerCommand, tx: &Sender<PlayerCommand>) -> Result<(), SendError> {
603 let here_now = |cmd| tx.send(cmd).map_err(|_| SendError::Stopped);
604 let Some(to) = devices::target() else {
605 return here_now(cmd);
606 };
607 match to_link(cmd).map_err(SendError::Unplayable)? {
608 Routed::There(link) => devices::send(&to, link).map_err(SendError::Missed),
609 Routed::Here(local) => here_now(local),
610 Routed::Dropped => Ok(()),
611 }
612}
613
614pub enum Routed {
616 There(LinkCommand),
618 Here(PlayerCommand),
620 Dropped,
623}
624
625fn server_ids(items: &[PlaylistItem]) -> Result<Vec<Option<String>>, String> {
629 if !devices::target_device().is_some_and(|d| d.same_library) {
630 return Err("that device plays from a different library".into());
631 }
632 let remote = crate::db::pool::shared()
633 .get()
634 .map(|db| remote_ids(&db, items))
635 .unwrap_or_default();
636 let out: Vec<Option<String>> = items
637 .iter()
638 .map(|i| i.db_id.and_then(|id| remote.get(&id).cloned()))
639 .collect();
640 if out.iter().all(Option::is_none) {
641 return Err("none of these are on the server, so the other device cannot play them".into());
642 }
643 Ok(out)
644}
645
646pub fn to_link(cmd: PlayerCommand) -> Result<Routed, String> {
650 let entry = |id: QueueItemId| id.0.to_string();
651 let tracks = |items: &[PlaylistItem]| -> Result<Vec<String>, String> {
652 Ok(server_ids(items)?.into_iter().flatten().collect())
653 };
654 Ok(Routed::There(match cmd {
655 PlayerCommand::Play(id) => LinkCommand::PlayItem { id: entry(id) },
656 PlayerCommand::Pause | PlayerCommand::Stop => LinkCommand::Pause,
657 PlayerCommand::Resume => LinkCommand::Resume,
658 PlayerCommand::Seek(position_ms) => LinkCommand::Seek { position_ms },
659 PlayerCommand::NextTrack => LinkCommand::Next,
660 PlayerCommand::PrevTrack => LinkCommand::Previous,
661 PlayerCommand::SetShuffle(on) => LinkCommand::Shuffle { on },
662 PlayerCommand::SetRepeat(mode) => LinkCommand::Repeat { mode },
663 PlayerCommand::SetSleepTimer(timer) => LinkCommand::SleepTimer { timer },
664 PlayerCommand::AddToPlaylist(items) => LinkCommand::Enqueue {
665 track_ids: tracks(&items)?,
666 play: false,
667 },
668 PlayerCommand::AddAndPlay(items) => LinkCommand::Enqueue {
669 track_ids: tracks(&items)?,
670 play: true,
671 },
672 PlayerCommand::InsertInPlaylist { items, after } => LinkCommand::Insert {
673 track_ids: tracks(&items)?,
674 after: entry(after),
675 },
676 PlayerCommand::ReplacePlaylist {
677 items,
678 start,
679 position_ms,
680 play,
681 mode,
682 } => {
683 let ids = server_ids(&items)?;
684 let found: Vec<Option<i64>> = ids.iter().map(|r| r.as_ref().map(|_| 0)).collect();
685 let (start_at, position_ms) = start_among(&found, start, position_ms);
686 LinkCommand::Play {
687 track_ids: ids.into_iter().flatten().collect(),
688 start_at: start_at as u32,
689 position_ms,
690 paused: !play,
691 handoff: false,
692 mode,
693 }
694 }
695 PlayerCommand::RemoveFromPlaylist(id) => LinkCommand::RemoveItems {
696 ids: vec![entry(id)],
697 },
698 PlayerCommand::RemoveFromPlaylistBatch(ids) => LinkCommand::RemoveItems {
699 ids: ids.into_iter().map(entry).collect(),
700 },
701 PlayerCommand::MoveInPlaylist { id, target, after } => LinkCommand::MoveItems {
702 ids: vec![entry(id)],
703 target: entry(target),
704 after,
705 },
706 PlayerCommand::MoveItemsInPlaylist { ids, target, after } => LinkCommand::MoveItems {
707 ids: ids.into_iter().map(entry).collect(),
708 target: entry(target),
709 after,
710 },
711 PlayerCommand::ClearPlaylist => LinkCommand::Clear,
712 PlayerCommand::Undo => LinkCommand::Undo,
713 PlayerCommand::Redo => LinkCommand::Redo,
714 PlayerCommand::ReorderPlaylist(_)
715 | PlayerCommand::BeginUndoBatch
716 | PlayerCommand::EndUndoBatch => return Ok(Routed::Dropped),
717 local @ (PlayerCommand::Cue { .. }
721 | PlayerCommand::PauseAndReport(_)
722 | PlayerCommand::Barrier(_)
723 | PlayerCommand::UpdatePaths(_)
724 | PlayerCommand::TrackReady(_)
725 | PlayerCommand::TrackStreamReady(_)
726 | PlayerCommand::StreamProbed { .. }
727 | PlayerCommand::TrackFailed(_)
728 | PlayerCommand::CacheTracks(_)
729 | PlayerCommand::DecodeFinished(_)
730 | PlayerCommand::TrackQueued
731 | PlayerCommand::SetOutputDevice(_)
732 | PlayerCommand::ClearOutputDevice
733 | PlayerCommand::ReloadDsp
734 | PlayerCommand::RouteChanged
735 | PlayerCommand::RestartOutput
736 | PlayerCommand::UseRenderer(_)
737 | PlayerCommand::ResumeRenderer(_)
738 | PlayerCommand::ResumeRendererMissed
739 | PlayerCommand::ReleaseRenderer(_)
740 | PlayerCommand::SetRendererVolume(_)
741 | PlayerCommand::RestorePlayMode(_)
742 | PlayerCommand::Renderer { .. }) => return Ok(Routed::Here(local)),
743 }))
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 #[test]
751 fn a_play_starts_on_the_track_asked_for_among_those_found() {
752 let found = [Some(1), None, Some(3), Some(4)];
753 assert_eq!(start_among(&found, 2, 500), (1, 500));
755 assert_eq!(start_among(&found, 1, 500), (1, 0));
757 assert_eq!(start_among(&[Some(1), None], 1, 500), (0, 0));
759 assert_eq!(start_among(&found, 0, 0), (0, 0));
760 }
761
762 #[test]
763 fn an_enqueue_that_does_not_play_reads_as_it_always_did() {
764 let plain = LinkCommand::Enqueue {
765 track_ids: vec!["t".into()],
766 play: false,
767 };
768 let json = serde_json::to_value(&plain).unwrap();
769 assert!(json.get("play").is_none(), "{json}");
770 let old: LinkCommand =
771 serde_json::from_value(serde_json::json!({"type": "enqueue", "trackIds": ["t"]}))
772 .unwrap();
773 assert_eq!(old, plain);
774 }
775}