1use std::collections::HashMap;
11use std::net::TcpStream;
12use std::os::fd::RawFd;
13use std::path::Path;
14use std::sync::{Arc, LazyLock};
15use std::time::{Duration, Instant};
16
17use parking_lot::{Condvar, Mutex};
18use serde::{Deserialize, Serialize};
19use tungstenite::stream::MaybeTlsStream;
20
21use crate::config::{self, Config};
22use crate::helpers::{subsonic_auth, subsonic_client};
23use crate::remote::client::SubsonicAuth;
24pub use crate::remote::outputs::{LinkOutput, LinkOutputs, OutputChoice};
25use crate::remote::profile;
26use crate::remote::wire::{self, Waker};
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(tag = "type", rename_all = "camelCase")]
31pub enum LinkCommand {
32 #[serde(rename_all = "camelCase")]
35 Play {
36 track_ids: Vec<String>,
37 #[serde(default)]
38 start_at: u32,
39 #[serde(default, skip_serializing_if = "is_zero")]
40 position_ms: u64,
41 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
42 paused: bool,
43 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
49 handoff: bool,
50 },
51 #[serde(rename_all = "camelCase")]
53 Enqueue {
54 track_ids: Vec<String>,
55 },
56 #[serde(rename_all = "camelCase")]
58 PlayNext {
59 track_ids: Vec<String>,
60 },
61 #[serde(rename_all = "camelCase")]
63 Remove {
64 track_ids: Vec<String>,
65 },
66 Clear,
67 Sync {
71 #[serde(default)]
72 full: bool,
73 },
74 #[serde(rename_all = "camelCase")]
77 Evict {
78 track_ids: Vec<String>,
79 },
80 #[serde(rename_all = "camelCase")]
83 JumpTo {
84 track_id: String,
85 },
86 #[serde(rename_all = "camelCase")]
87 Seek {
88 position_ms: u64,
89 },
90 Pause,
91 Resume,
92 Next,
93 Previous,
94 PlayItem {
98 id: String,
99 },
100 RemoveItems {
101 ids: Vec<String>,
102 },
103 MoveItems {
105 ids: Vec<String>,
106 target: String,
107 after: bool,
108 },
109 #[serde(rename_all = "camelCase")]
111 Insert {
112 track_ids: Vec<String>,
113 after: String,
114 },
115 Undo,
116 Redo,
117 Shuffle {
120 on: bool,
121 },
122 Repeat {
124 mode: crate::player::state::Repeat,
125 },
126 HandOff {
130 to: String,
131 },
132 Devices {
135 devices: Vec<LinkDevice>,
136 },
137 SetOutput {
140 output: OutputChoice,
141 },
142 SetRendererVolume {
144 volume: u8,
145 },
146 SetPreset {
149 device: String,
150 profile: Option<String>,
151 },
152}
153
154fn is_zero(n: &u64) -> bool {
155 *n == 0
156}
157
158impl LinkCommand {
159 pub fn allowed_nearby(&self) -> bool {
166 !matches!(
167 self,
168 Self::Sync { .. }
169 | Self::Evict { .. }
170 | Self::Devices { .. }
171 | Self::SetOutput { .. }
172 | Self::SetRendererVolume { .. }
173 | Self::SetPreset { .. }
174 )
175 }
176
177 pub fn track_ids(&self) -> &[String] {
179 match self {
180 Self::Play { track_ids, .. }
181 | Self::Enqueue { track_ids }
182 | Self::PlayNext { track_ids }
183 | Self::Remove { track_ids }
184 | Self::Evict { track_ids }
185 | Self::Insert { track_ids, .. } => track_ids,
186 Self::JumpTo { track_id } => std::slice::from_ref(track_id),
187 _ => &[],
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum CommandSource {
195 Account,
197 Nearby,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct LinkDevice {
205 pub id: String,
207 pub name: String,
208 pub platform: String,
209 pub linked: bool,
212 pub state: Option<LinkState>,
215}
216
217impl LinkCommand {
218 pub fn track_ids_mut(&mut self) -> Vec<&mut String> {
221 match self {
222 Self::Play { track_ids, .. }
223 | Self::Enqueue { track_ids }
224 | Self::PlayNext { track_ids }
225 | Self::Remove { track_ids }
226 | Self::Evict { track_ids }
227 | Self::Insert { track_ids, .. } => track_ids.iter_mut().collect(),
228 Self::JumpTo { track_id } => vec![track_id],
229 Self::PlayItem { .. }
230 | Self::RemoveItems { .. }
231 | Self::MoveItems { .. }
232 | Self::Undo
233 | Self::Redo
234 | Self::Shuffle { .. }
235 | Self::Repeat { .. }
236 | Self::HandOff { .. }
237 | Self::Devices { .. }
238 | Self::SetOutput { .. }
239 | Self::SetRendererVolume { .. }
240 | Self::SetPreset { .. }
241 | Self::Clear
242 | Self::Sync { .. }
243 | Self::Seek { .. }
244 | Self::Pause
245 | Self::Resume
246 | Self::Next
247 | Self::Previous => Vec::new(),
248 }
249 }
250}
251
252#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct LinkState {
256 pub playing: bool,
257 pub title: Option<String>,
258 pub artist: Option<String>,
259 #[serde(default)]
260 pub album: Option<String>,
261 #[serde(default)]
263 pub position_ms: u64,
264 #[serde(default)]
265 pub duration_ms: u64,
266 #[serde(default)]
268 pub queue: Vec<LinkQueueEntry>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub outputs: Option<LinkOutputs>,
272 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
273 pub shuffle: bool,
274 #[serde(default, skip_serializing_if = "crate::player::state::Repeat::is_off")]
275 pub repeat: crate::player::state::Repeat,
276}
277
278#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct LinkQueueEntry {
281 #[serde(default)]
283 pub id: Option<String>,
284 pub track_id: Option<String>,
286 pub title: String,
287 pub artist: String,
288 #[serde(default)]
289 pub album: String,
290 #[serde(default)]
291 pub duration_ms: u64,
292 pub current: bool,
293}
294
295impl LinkState {
296 pub fn differs(&self, sent: &LinkState, elapsed: Duration) -> bool {
300 let strip = |s: &LinkState| LinkState {
301 position_ms: 0,
302 ..s.clone()
303 };
304 if strip(self) != strip(sent) {
305 return true;
306 }
307 let expected = if sent.playing {
308 sent.position_ms + elapsed.as_millis() as u64
309 } else {
310 sent.position_ms
311 };
312 self.position_ms.abs_diff(expected) > 3000
313 }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(tag = "type", rename_all = "camelCase")]
319pub enum LinkReport {
320 State(LinkState),
321 Push {
325 token: String,
326 sandbox: bool,
327 },
328 Command {
330 to: String,
331 command: LinkCommand,
332 },
333 Activity {
336 token: Option<String>,
337 device: Option<String>,
338 #[serde(default)]
339 sandbox: bool,
340 },
341 Hello(LinkHello),
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "camelCase")]
349pub struct LinkHello {
350 pub id: String,
351 pub name: String,
352 pub platform: String,
353 pub library: Option<String>,
357}
358
359pub fn library_fingerprint(cfg: &Config) -> Option<String> {
362 let auth = subsonic_auth(cfg)?;
363 let url = auth.base_url.trim_end_matches('/').to_ascii_lowercase();
364 Some(format!("{:x}", md5::compute(url.as_bytes())))
365}
366
367static PUSH_TOKEN: Mutex<Option<(String, bool)>> = Mutex::new(None);
370
371type ActivityToken = (String, String, bool);
375static ACTIVITY: Mutex<Option<Option<ActivityToken>>> = Mutex::new(None);
376
377pub fn set_activity(activity: Option<ActivityToken>) {
380 *ACTIVITY.lock() = Some(activity);
381 if let Some(up) = LINK.lock().as_ref() {
382 up.waker.wake();
383 }
384}
385
386pub fn parse_command(json: &str) -> Result<LinkCommand, String> {
389 serde_json::from_str(json).map_err(|e| e.to_string())
390}
391
392pub fn set_push_token(token: String, sandbox: bool) {
394 *PUSH_TOKEN.lock() = Some((token, sandbox));
395 nudge();
396 if let Some(up) = LINK.lock().as_ref() {
397 up.waker.wake();
398 }
399}
400
401#[derive(Debug, Clone)]
403pub struct LinkIdentity {
404 pub name: String,
406 pub platform: String,
408 pub device_id: String,
411}
412
413impl LinkIdentity {
414 pub fn this_device(name: Option<String>) -> Self {
416 let (platform, label) = if cfg!(target_os = "ios") {
417 ("ios", "iPhone")
418 } else if cfg!(target_os = "macos") {
419 ("macos", "Mac")
420 } else {
421 ("linux", "Linux")
422 };
423 Self {
424 name: name
425 .filter(|n| !n.trim().is_empty())
426 .or_else(hostname)
427 .unwrap_or_else(|| label.to_string()),
428 platform: platform.to_string(),
429 device_id: device_id(&config::config_dir()),
430 }
431 }
432}
433
434#[derive(Clone)]
438pub struct Local {
439 pub identity: LinkIdentity,
440 pub state: Arc<dyn Fn() -> LinkState + Send + Sync>,
441 pub on_command: Arc<dyn Fn(LinkCommand, CommandSource) + Send + Sync>,
442}
443
444pub fn spawn(local: Local) {
453 std::thread::Builder::new()
454 .name("koan-link".into())
455 .spawn(move || run(local))
456 .expect("failed to spawn the link thread");
457}
458
459const RETRY_MIN: Duration = Duration::from_secs(2);
460const RETRY_MAX: Duration = Duration::from_secs(60);
461
462fn run(local: Local) {
463 let mut wait = RETRY_MIN;
464 loop {
465 crate::quiet::wait_until_awake();
466 let cfg = Config::load().unwrap_or_default();
467 let Some(auth) = subsonic_auth(&cfg) else {
468 rest(RETRY_MAX);
469 continue;
470 };
471 match profile::for_auth(&auth) {
472 Some(p) if p.links() => {}
473 Some(_) => {
475 rest(RETRY_MAX);
476 continue;
477 }
478 None => {
479 rest(wait);
480 wait = (wait * 2).min(RETRY_MAX);
481 continue;
482 }
483 }
484
485 match connect(&auth, &local.identity) {
486 Ok((mut socket, fd)) => {
487 log::info!("link: connected to {}", auth.base_url);
488 if let Some(client) = crate::helpers::subsonic_client(&cfg) {
491 client.outage().retry_now();
492 }
493 wait = RETRY_MIN;
494 if let Err(e) = serve(&mut socket, fd, &local) {
495 log::info!("link: closed: {e}");
496 }
497 *LINK.lock() = None;
498 crate::remote::devices::set_linked(false);
499 }
500 Err(e) => {
501 log::warn!("link: {e}");
502 profile::forget();
508 }
509 }
510 if rest(wait) {
511 wait = RETRY_MIN;
512 continue;
513 }
514 wait = (wait * 2).min(RETRY_MAX);
515 }
516}
517
518static NUDGE: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
519
520pub fn nudge() {
526 *NUDGE.0.lock() = true;
527 NUDGE.1.notify_all();
528}
529
530fn rest(d: Duration) -> bool {
532 let mut nudged = NUDGE.0.lock();
533 if !*nudged {
534 NUDGE.1.wait_for(&mut nudged, d);
535 }
536 std::mem::replace(&mut *nudged, false)
537}
538
539pub fn hang_up() {
541 if let Some(up) = LINK.lock().as_ref() {
542 up.waker.wake();
543 }
544}
545
546struct Up {
548 waker: Arc<Waker>,
549 outbox: Vec<LinkReport>,
550}
551
552static LINK: Mutex<Option<Up>> = Mutex::new(None);
553
554pub fn report(report: LinkReport) -> bool {
556 let mut link = LINK.lock();
557 let Some(up) = link.as_mut() else {
558 return false;
559 };
560 up.outbox.push(report);
561 up.waker.wake();
562 true
563}
564
565type Socket = tungstenite::WebSocket<MaybeTlsStream<TcpStream>>;
566
567fn connect(auth: &SubsonicAuth, identity: &LinkIdentity) -> Result<(Socket, RawFd), String> {
568 let url = link_url(auth, identity)?;
569 let (socket, _) = tungstenite::connect(url).map_err(|e| e.to_string())?;
570 let fd = wire::prepare(socket.get_ref())?;
571 Ok((socket, fd))
572}
573
574fn link_url(auth: &SubsonicAuth, identity: &LinkIdentity) -> Result<String, String> {
576 let base = if let Some(rest) = auth.base_url.strip_prefix("https://") {
577 format!("wss://{rest}")
578 } else if let Some(rest) = auth.base_url.strip_prefix("http://") {
579 format!("ws://{rest}")
580 } else {
581 return Err(format!("not an http(s) server: {}", auth.base_url));
582 };
583 let mut query = auth.query().map_err(|e| e.to_string())?;
584 for (k, v) in [
585 ("client", identity.name.as_str()),
586 ("platform", identity.platform.as_str()),
587 ("device", identity.device_id.as_str()),
588 ("devices", "1"),
591 ] {
592 query.push('&');
593 query.push_str(k);
594 query.push('=');
595 query.push_str(&percent_encode(v));
596 }
597 Ok(format!("{base}/rest/koanLink?{query}"))
598}
599
600fn serve(socket: &mut Socket, fd: RawFd, local: &Local) -> Result<(), String> {
601 let waker = Waker::new().map_err(|e| e.to_string())?;
602 wire::wake_on_engine_change(&waker);
603 *LINK.lock() = Some(Up {
604 waker: waker.clone(),
605 outbox: Vec::new(),
606 });
607 crate::remote::devices::set_linked(true);
608 let mut session = LinkSession {
609 local,
610 sent: None,
611 sent_push: None,
612 sent_activity: None,
613 };
614 wire::drive(socket, fd, &waker, &mut session)
615}
616
617struct LinkSession<'a> {
618 local: &'a Local,
619 sent: Option<(LinkState, Instant)>,
620 sent_push: Option<(String, bool)>,
621 sent_activity: Option<Option<ActivityToken>>,
622}
623
624impl wire::Session for LinkSession<'_> {
625 fn outgoing(&mut self) -> Vec<String> {
626 let mut out = Vec::new();
627 let push = PUSH_TOKEN.lock().clone();
628 if let Some((token, sandbox)) = push.clone()
629 && push != self.sent_push
630 {
631 out.push(LinkReport::Push { token, sandbox });
632 self.sent_push = push;
633 }
634 let activity = ACTIVITY.lock().clone();
635 if activity.is_some() && activity != self.sent_activity {
636 let (token, device, sandbox) = match activity.clone().flatten() {
637 Some((t, d, s)) => (Some(t), Some(d), s),
638 None => (None, None, false),
639 };
640 out.push(LinkReport::Activity {
641 token,
642 device,
643 sandbox,
644 });
645 self.sent_activity = activity;
646 }
647 if let Some(up) = LINK.lock().as_mut() {
648 out.append(&mut up.outbox);
649 }
650 let now = (self.local.state)();
651 if self
652 .sent
653 .as_ref()
654 .is_none_or(|(s, at)| now.differs(s, at.elapsed()))
655 {
656 out.push(LinkReport::State(now.clone()));
657 self.sent = Some((now, Instant::now()));
658 }
659 out.iter()
660 .filter_map(|r| serde_json::to_string(r).ok())
661 .collect()
662 }
663
664 fn incoming(&mut self, text: &str) {
665 match serde_json::from_str::<LinkCommand>(text) {
666 Ok(LinkCommand::Devices { devices }) => {
667 crate::remote::devices::set_account(devices);
668 }
669 Ok(cmd) => (self.local.on_command)(cmd, CommandSource::Account),
670 Err(e) => log::warn!("link: not a command ({e}): {text}"),
671 }
672 }
673
674 fn done(&self) -> bool {
675 !crate::quiet::awake()
676 }
677}
678
679pub fn resolve_tracks(
690 db: &crate::db::connection::Database,
691 remote_ids: &[String],
692 may_sync: bool,
693) -> (Vec<i64>, bool) {
694 let lookup = |db: &crate::db::connection::Database| {
695 let mut stmt = db
696 .conn
697 .prepare_cached(
698 "SELECT id FROM tracks WHERE uid = ?1
699 UNION ALL SELECT id FROM tracks WHERE remote_id = ?1 LIMIT 1",
700 )
701 .ok();
702 remote_ids
703 .iter()
704 .map(|rid| {
705 stmt.as_mut()
706 .and_then(|s| s.query_row([rid], |r| r.get::<_, i64>(0)).ok())
707 })
708 .collect::<Vec<_>>()
709 };
710 let found = lookup(db);
711 let missing: Vec<&String> = remote_ids
712 .iter()
713 .zip(&found)
714 .filter(|(_, f)| f.is_none())
715 .map(|(id, _)| id)
716 .collect();
717 if missing.is_empty() || !may_sync || missing.iter().all(|id| recently_missed(id)) {
718 return (found.into_iter().flatten().collect(), false);
719 }
720 sync(db, crate::helpers::Walk::IfChanged);
721 let found = lookup(db);
722 let mut missed = MISSED.lock();
723 let now = Instant::now();
724 missed.retain(|_, at| now.duration_since(*at) < MISS_TTL);
725 for (id, _) in remote_ids.iter().zip(&found).filter(|(_, f)| f.is_none()) {
726 missed.insert(id.clone(), now);
727 }
728 (found.into_iter().flatten().collect(), true)
729}
730
731static MISSED: LazyLock<Mutex<HashMap<String, Instant>>> = LazyLock::new(Default::default);
733const MISS_TTL: Duration = Duration::from_secs(300);
734
735fn recently_missed(id: &str) -> bool {
736 MISSED
737 .lock()
738 .get(id)
739 .is_some_and(|at| at.elapsed() < MISS_TTL)
740}
741
742pub fn sync(db: &crate::db::connection::Database, walk: crate::helpers::Walk) {
745 let cfg = Config::load().unwrap_or_default();
746 if let Some(client) = subsonic_client(&cfg)
747 && let Err(e) = crate::helpers::sync_remote(
748 db,
749 &client,
750 walk,
751 &cfg.remote.url,
752 &cfg.remote.username,
753 &|_| {},
754 )
755 {
756 log::warn!("link: sync failed: {e}");
757 }
758}
759
760fn device_id(dir: &Path) -> String {
764 #[cfg(target_os = "ios")]
765 {
766 use security_framework::passwords::{get_generic_password, set_generic_password};
767 const SERVICE: &str = "cc.blit.koan.link";
768 if let Some(id) = get_generic_password(SERVICE, "device-id")
769 .ok()
770 .and_then(|b| String::from_utf8(b).ok())
771 .filter(|id| !id.trim().is_empty())
772 {
773 return id;
774 }
775 let id = file_device_id(dir);
776 let _ = set_generic_password(SERVICE, "device-id", id.as_bytes());
777 id
778 }
779 #[cfg(not(target_os = "ios"))]
780 file_device_id(dir)
781}
782
783fn file_device_id(dir: &Path) -> String {
784 let path = dir.join("device-id");
785 if let Ok(id) = std::fs::read_to_string(&path) {
786 let id = id.trim();
787 if !id.is_empty() {
788 return id.to_string();
789 }
790 }
791 let id = uuid::Uuid::now_v7().to_string();
792 let _ = std::fs::create_dir_all(dir);
793 let _ = std::fs::write(&path, &id);
794 id
795}
796
797fn hostname() -> Option<String> {
798 let mut buf = [0u8; 256];
799 let ok = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } == 0;
802 if !ok {
803 return None;
804 }
805 let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
806 let name = String::from_utf8_lossy(&buf[..end]);
807 let name = name.trim_end_matches(".local").trim();
808 (!name.is_empty() && name != "localhost").then(|| name.to_string())
809}
810
811fn percent_encode(s: &str) -> String {
812 let mut out = String::with_capacity(s.len());
813 for b in s.bytes() {
814 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
815 out.push(b as char);
816 } else {
817 out.push_str(&format!("%{b:02X}"));
818 }
819 }
820 out
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826
827 #[test]
830 fn unknown_ids_sync_only_when_allowed_and_not_recently_missed() {
831 let dir = tempfile::tempdir().unwrap();
832 let db = crate::db::connection::Database::open(&dir.path().join("koan.db")).unwrap();
833
834 let (found, synced) = resolve_tracks(&db, &["from-a-stranger".into()], false);
835 assert!(found.is_empty());
836 assert!(!synced, "a nearby peer's unknown id must not start a sync");
837
838 MISSED
839 .lock()
840 .insert("deleted-on-server".into(), Instant::now());
841 let (_, synced) = resolve_tracks(&db, &["deleted-on-server".into()], true);
842 assert!(
843 !synced,
844 "an id a sync just failed to find does not start another"
845 );
846 }
847
848 #[test]
849 fn commands_name_their_tracks() {
850 let play: LinkCommand =
851 serde_json::from_str(r#"{"type":"play","trackIds":["a","b"]}"#).unwrap();
852 assert_eq!(play.track_ids(), ["a", "b"]);
853 let jump: LinkCommand = serde_json::from_str(r#"{"type":"jumpTo","trackId":"c"}"#).unwrap();
854 assert_eq!(jump.track_ids(), ["c"]);
855 assert!(LinkCommand::Pause.track_ids().is_empty());
856 }
857
858 #[test]
859 fn a_push_token_is_a_tagged_report() {
860 let report = LinkReport::Push {
861 token: "ab12".into(),
862 sandbox: true,
863 };
864 let text = serde_json::to_string(&report).unwrap();
865 assert_eq!(text, r#"{"type":"push","token":"ab12","sandbox":true}"#);
866 assert_eq!(serde_json::from_str::<LinkReport>(&text).unwrap(), report);
867 }
868
869 #[test]
870 fn commands_are_tagged_json() {
871 let play = LinkCommand::Play {
872 track_ids: vec!["12".into(), "34".into()],
873 start_at: 1,
874 position_ms: 0,
875 paused: false,
876 handoff: false,
877 };
878 let json = serde_json::to_string(&play).unwrap();
879 assert_eq!(
880 json,
881 r#"{"type":"play","trackIds":["12","34"],"startAt":1}"#
882 );
883 assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), play);
884 let held = LinkCommand::Play {
885 track_ids: vec!["12".into()],
886 start_at: 0,
887 position_ms: 61_250,
888 paused: true,
889 handoff: false,
890 };
891 let json = serde_json::to_string(&held).unwrap();
892 assert_eq!(
893 json,
894 r#"{"type":"play","trackIds":["12"],"startAt":0,"positionMs":61250,"paused":true}"#
895 );
896 assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), held);
897 assert_eq!(
898 serde_json::from_str::<LinkCommand>(r#"{"type":"pause"}"#).unwrap(),
899 LinkCommand::Pause
900 );
901 let report = LinkReport::State(LinkState {
902 playing: true,
903 title: Some("Portions for Foxes".into()),
904 ..Default::default()
905 });
906 let json = serde_json::to_string(&report).unwrap();
907 assert!(json.starts_with(r#"{"type":"state","playing":true,"title":"Portions for Foxes""#));
908 assert_eq!(serde_json::from_str::<LinkReport>(&json).unwrap(), report);
909 }
910
911 #[test]
912 fn a_playhead_moving_on_time_is_not_news() {
913 let sent = LinkState {
914 playing: true,
915 position_ms: 10_000,
916 ..Default::default()
917 };
918 let later = |pos| LinkState {
919 position_ms: pos,
920 ..sent.clone()
921 };
922 let five = Duration::from_secs(5);
923 assert!(!later(15_000).differs(&sent, five));
924 assert!(later(60_000).differs(&sent, five), "a seek");
925 let paused = LinkState {
926 playing: false,
927 ..later(15_000)
928 };
929 assert!(paused.differs(&sent, five));
930 }
931
932 #[test]
933 fn the_url_follows_the_scheme_and_names_the_device() {
934 let identity = LinkIdentity {
935 name: "J's iPhone".into(),
936 platform: "ios".into(),
937 device_id: "abc".into(),
938 };
939 let url = link_url(
940 &SubsonicAuth::new("https://music.example.com", "j", "pw"),
941 &identity,
942 )
943 .unwrap();
944 assert!(url.starts_with("wss://music.example.com/rest/koanLink?"));
945 assert!(url.contains("client=J%27s%20iPhone"));
946 assert!(url.contains("device=abc"));
947 assert!(url.contains("devices=1"));
948 assert!(
949 link_url(&SubsonicAuth::new("http://h:4000", "j", "pw"), &identity)
950 .unwrap()
951 .starts_with("ws://h:4000/")
952 );
953 }
954
955 #[test]
956 fn a_relayed_command_nests_the_command() {
957 let report = LinkReport::Command {
958 to: "phone".into(),
959 command: LinkCommand::HandOff { to: "mac".into() },
960 };
961 let json = serde_json::to_string(&report).unwrap();
962 assert_eq!(
963 json,
964 r#"{"type":"command","to":"phone","command":{"type":"handOff","to":"mac"}}"#
965 );
966 assert_eq!(serde_json::from_str::<LinkReport>(&json).unwrap(), report);
967 }
968
969 #[test]
970 fn an_older_queue_entry_still_reads() {
971 let e: LinkQueueEntry =
972 serde_json::from_str(r#"{"trackId":"7","title":"t","artist":"a","current":true}"#)
973 .unwrap();
974 assert_eq!(e.id, None);
975 assert_eq!(e.duration_ms, 0);
976 }
977
978 #[test]
979 fn strangers_cannot_touch_the_library() {
980 assert!(LinkCommand::Pause.allowed_nearby());
981 assert!(LinkCommand::HandOff { to: "x".into() }.allowed_nearby());
982 assert!(!LinkCommand::Sync { full: true }.allowed_nearby());
983 assert!(!LinkCommand::Evict { track_ids: vec![] }.allowed_nearby());
984 }
985
986 #[test]
987 fn the_device_id_is_kept() {
988 let dir = tempfile::tempdir().unwrap();
989 let first = device_id(dir.path());
990 assert_eq!(device_id(dir.path()), first);
991 }
992}