1use std::sync::LazyLock;
10
11use koan_core::db::queries::{self, UidKind};
12use koan_core::remote::acks::{AckOutcome, Envelope};
13use koan_core::remote::link::{LinkCommand, LinkDevice, LinkState};
14use outbox::Absent;
15use parking_lot::Mutex;
16use tokio::sync::mpsc::UnboundedSender;
17
18#[derive(Debug, Clone)]
20pub struct ClientInfo {
21 pub id: String,
22 pub device: String,
24 pub name: String,
25 pub platform: String,
26 pub username: String,
27 pub connected_at: i64,
29 pub state: LinkState,
31 pub last_played_at: Option<i64>,
33 pub state_at: i64,
35 pub reports: bool,
38 pub notified: bool,
41 pub acks: bool,
43}
44
45impl ClientInfo {
46 pub fn position_ms(&self) -> u64 {
48 let pos = self.state.position_ms;
49 if !self.state.playing {
50 return pos;
51 }
52 let run = (chrono::Utc::now().timestamp_millis() - self.state_at).max(0) as u64;
53 let pos = pos + run;
54 if self.state.duration_ms > 0 {
55 pos.min(self.state.duration_ms)
56 } else {
57 pos
58 }
59 }
60}
61
62struct Entry {
63 info: ClientInfo,
64 device: String,
66 tx: UnboundedSender<Envelope>,
67 wants_devices: bool,
70 wants_keys: bool,
73}
74
75pub struct Acking {
78 pub id: u64,
79 pub reply: Box<dyn FnOnce(Option<AckOutcome>) + Send>,
80}
81
82struct Waiting {
84 reply: Box<dyn FnOnce(Option<AckOutcome>) + Send>,
85 received: bool,
87 timer: Option<tokio::task::AbortHandle>,
90}
91
92impl Waiting {
93 fn answer(mut self, outcome: Option<AckOutcome>) {
94 let reply = std::mem::replace(&mut self.reply, Box::new(|_| {}));
95 reply(outcome);
96 }
97}
98
99impl Drop for Waiting {
100 fn drop(&mut self) {
101 if let Some(timer) = self.timer.take() {
102 timer.abort();
103 }
104 }
105}
106
107const FIRST_LOOK: std::time::Duration = std::time::Duration::from_millis(2500);
112const LONGEST_ANSWER: std::time::Duration = std::time::Duration::from_secs(120);
113
114struct Activity {
117 username: String,
118 watcher: String,
120 target: String,
122 handle: String,
124 sent: Option<koan_relay_client::ActivityState>,
126}
127
128#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
131pub struct Order {
132 pub id: String,
133 pub username: Option<String>,
135 pub client: Option<String>,
137 pub artist: String,
138 pub album: String,
139 pub play_next: bool,
141 #[serde(default)]
143 pub playlist: Option<i64>,
144 #[serde(default)]
147 pub titles: Vec<String>,
148 pub created_at: i64,
150}
151
152const ORDER_TTL: i64 = 24 * 60 * 60;
154
155#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Grant {
160 pub device: String,
161 pub owner: String,
162 pub grantee: String,
163}
164
165#[derive(Default)]
166pub struct Registry {
167 entries: Mutex<Vec<Entry>>,
168 orders: Mutex<Vec<Order>>,
169 activities: Mutex<Vec<Activity>>,
170 grants: Mutex<Vec<Grant>>,
171 level_watches: Mutex<Vec<LevelWatch>>,
174 addresses: Mutex<std::collections::HashMap<String, (String, std::net::IpAddr)>>,
177 answers: Mutex<std::collections::HashMap<(String, String, u64), Waiting>>,
182}
183
184struct LevelWatch {
188 owner: String,
189 watcher_user: String,
190 watcher: String,
191 target: String,
192}
193
194pub fn registry() -> &'static Registry {
197 static REGISTRY: LazyLock<Registry> = LazyLock::new(|| {
198 let registry = Registry::default();
199 *registry.orders.lock() = outbox::load_orders();
200 *registry.grants.lock() = outbox::load_grants();
201 *registry.addresses.lock() = outbox::load_addresses();
202 registry
203 });
204 ®ISTRY
205}
206
207impl Registry {
208 #[allow(clippy::too_many_arguments)]
211 pub fn register(
212 &self,
213 username: &str,
214 name: &str,
215 platform: &str,
216 device: &str,
217 tx: UnboundedSender<Envelope>,
218 wants_devices: bool,
219 acks: bool,
220 ) -> String {
221 let id = uuid::Uuid::now_v7().to_string();
222 if let Some((sent, how)) = WOKEN
223 .lock()
224 .remove(&(device.to_string(), username.to_string()))
225 {
226 log::info!(
227 "wake: {name} linked {}ms after its {how}",
228 sent.elapsed().as_millis()
229 );
230 }
231 let live = self.live();
234 for envelope in outbox::take_and_remember(username, device, name, platform, &live) {
235 let _ = tx.send(envelope);
236 }
237 let mut entries = self.entries.lock();
238 entries.retain(|e| !(e.device == device && e.info.username == username));
239 entries.push(Entry {
240 info: ClientInfo {
241 id: id.clone(),
242 device: device.to_string(),
243 name: name.to_string(),
244 platform: platform.to_string(),
245 username: username.to_string(),
246 connected_at: chrono::Utc::now().timestamp(),
247 state: LinkState::default(),
248 last_played_at: None,
249 state_at: chrono::Utc::now().timestamp_millis(),
250 reports: false,
251 notified: false,
252 acks,
253 },
254 device: device.to_string(),
255 tx,
256 wants_devices,
257 wants_keys: false,
258 });
259 drop(entries);
260 self.send_shares(username, device, None);
263 self.announce(username);
264 if self
267 .level_watches
268 .lock()
269 .iter()
270 .any(|w| w.owner == username && w.target == device)
271 {
272 self.send_live(username, device, LinkCommand::WatchLevels { on: true });
273 }
274 id
275 }
276
277 pub fn wants_keys(&self, id: &str) {
279 if let Some(e) = self.entries.lock().iter_mut().find(|e| e.info.id == id) {
280 e.wants_keys = true;
281 }
282 }
283
284 pub fn publish_keys(&self, username: &str, keys: Vec<koan_core::remote::link::LinkDeviceKey>) {
287 for e in self
288 .entries
289 .lock()
290 .iter()
291 .filter(|e| e.info.username == username && e.wants_keys)
292 {
293 let _ =
294 e.tx.send(LinkCommand::DeviceKeys { keys: keys.clone() }.into());
295 }
296 }
297
298 pub fn keyed_accounts(&self) -> Vec<String> {
300 let mut accounts: Vec<String> = self
301 .entries
302 .lock()
303 .iter()
304 .filter(|e| e.wants_keys)
305 .map(|e| e.info.username.clone())
306 .collect();
307 accounts.sort();
308 accounts.dedup();
309 accounts
310 }
311
312 pub fn grantees_of(&self, owner: &str, device: &str) -> Vec<String> {
314 self.grants
315 .lock()
316 .iter()
317 .filter(|g| g.owner == owner && g.device == device)
318 .map(|g| g.grantee.clone())
319 .collect()
320 }
321
322 pub fn report(&self, id: &str, state: LinkState) {
324 let mut entries = self.entries.lock();
325 if let Some(e) = entries.iter_mut().find(|e| e.info.id == id) {
326 if state.playing || e.info.state.playing {
327 e.info.last_played_at = Some(chrono::Utc::now().timestamp());
328 }
329 e.info.state = state;
330 e.info.state_at = chrono::Utc::now().timestamp_millis();
331 e.info.reports = true;
332 let (username, device) = (e.info.username.clone(), e.device.clone());
333 drop(entries);
334 self.announce(&username);
335 self.update_activities(&username, &device);
336 }
337 }
338
339 pub fn set_push(&self, username: &str, device: &str, handle: &str) {
341 outbox::save_push(username, device, handle);
342 }
343
344 pub fn set_tv_pairing(
348 &self,
349 username: &str,
350 device: &str,
351 pairing: Option<&koan_core::remote::companion::Credential>,
352 ) -> Result<(), String> {
353 if pairing.is_some() && !outbox::is_tv(username, device) {
354 return Err(format!("{device} is not one of {username}'s Apple TVs"));
355 }
356 outbox::save_tv_pairing(username, device, pairing);
357 log::info!(
358 "devices: {username} {} the Apple TV {device}",
359 if pairing.is_some() {
360 "paired"
361 } else {
362 "unpaired"
363 }
364 );
365 self.announce(username);
366 Ok(())
367 }
368
369 pub fn disconnect(&self, username: &str) {
372 self.entries.lock().retain(|e| e.info.username != username);
373 }
374
375 pub fn unregister(&self, id: &str) {
376 let mut entries = self.entries.lock();
377 let gone = entries
378 .iter()
379 .find(|e| e.info.id == id)
380 .map(|e| (e.device.clone(), e.info.username.clone()));
381 entries.retain(|e| e.info.id != id);
382 drop(entries);
383 if let Some((device, username)) = gone {
384 outbox::touch(&[(device.clone(), username.clone())]);
385 self.announce(&username);
386 let targets: Vec<String> = self
388 .level_watches
389 .lock()
390 .iter()
391 .filter(|w| w.watcher_user == username && w.watcher == device)
392 .map(|w| w.target.clone())
393 .collect();
394 for target in targets {
395 self.watch_levels(&username, &device, &target, false);
396 }
397 }
398 }
399
400 pub fn watch_levels(&self, username: &str, watcher: &str, target: &str, on: bool) {
405 let owner = self
407 .shared_owner(username, target)
408 .unwrap_or_else(|| username.to_string());
409 let mut watches = self.level_watches.lock();
410 watches.retain(|w| {
411 !(w.watcher_user == username && w.watcher == watcher && w.target == target)
412 });
413 if on {
414 watches.push(LevelWatch {
415 owner: owner.clone(),
416 watcher_user: username.into(),
417 watcher: watcher.into(),
418 target: target.into(),
419 });
420 }
421 let watched = watches
422 .iter()
423 .any(|w| w.owner == owner && w.target == target);
424 drop(watches);
425 if on || !watched {
426 self.send_live(&owner, target, LinkCommand::WatchLevels { on: watched });
427 }
428 }
429
430 pub fn levels(&self, username: &str, from: &str, f: koan_core::remote::levels::Frame) {
433 let watchers: Vec<(String, String)> = self
434 .level_watches
435 .lock()
436 .iter()
437 .filter(|w| w.owner == username && w.target == from)
438 .map(|w| (w.watcher_user.clone(), w.watcher.clone()))
439 .collect();
440 for (watcher_user, watcher) in watchers {
441 self.send_live(
442 &watcher_user,
443 &watcher,
444 LinkCommand::Levels {
445 from: from.to_string(),
446 f,
447 },
448 );
449 }
450 }
451
452 pub(crate) fn send_kept(&self, username: &str, device: &str, envelope: Envelope) {
455 if let Some(e) = self
456 .entries
457 .lock()
458 .iter()
459 .find(|e| e.info.username == username && e.device == device)
460 {
461 let _ = e.tx.send(envelope);
462 }
463 }
464
465 pub(crate) fn send_live(&self, username: &str, device: &str, cmd: LinkCommand) {
467 if let Some(e) = self
468 .entries
469 .lock()
470 .iter()
471 .find(|e| e.info.username == username && e.device == device)
472 {
473 let _ = e.tx.send(cmd.into());
474 }
475 }
476
477 fn announce(&self, username: &str) {
481 self.announce_to(username);
482 let grantees: Vec<String> = {
484 let mut g: Vec<String> = self
485 .grants
486 .lock()
487 .iter()
488 .filter(|g| g.owner == username)
489 .map(|g| g.grantee.clone())
490 .collect();
491 g.sort();
492 g.dedup();
493 g
494 };
495 for grantee in grantees {
496 self.announce_to(&grantee);
497 }
498 }
499
500 fn announce_to(&self, username: &str) {
502 let listening = |e: &Entry| e.info.username == username && e.wants_devices;
503 if !self.entries.lock().iter().any(listening) {
504 return;
505 }
506 let asleep = outbox::push_targets(Some(username));
507 let tvs = outbox::tv_pairings(username);
508 let can_push = crate::push::pusher().is_some();
510 let entries = self.entries.lock();
511 let ours: Vec<&Entry> = entries
512 .iter()
513 .filter(|e| e.info.username == username)
514 .collect();
515 if !ours.iter().any(|e| e.wants_devices) {
516 return;
517 }
518 let mut all: Vec<LinkDevice> = ours
519 .iter()
520 .map(|e| LinkDevice {
521 id: e.device.clone(),
522 name: e.info.name.clone(),
523 platform: e.info.platform.clone(),
524 linked: true,
525 state: e.info.reports.then(|| LinkState {
526 position_ms: e.info.position_ms(),
527 ..e.info.state.clone()
528 }),
529 last_seen: None,
530 wakeable: Some(can_push && asleep.iter().any(|t| t.device == e.device)),
531 owner: None,
532 acks: e.info.acks,
533 tv_pairing: tvs
534 .iter()
535 .find(|t| t.device == e.device)
536 .map(|t| t.pairing.clone()),
537 })
538 .collect();
539 for t in asleep {
540 if !all.iter().any(|d| d.id == t.device) {
541 all.push(LinkDevice {
542 tv_pairing: tvs
543 .iter()
544 .find(|p| p.device == t.device)
545 .map(|p| p.pairing.clone()),
546 id: t.device,
547 name: t.name,
548 platform: t.platform,
549 linked: false,
550 state: None,
551 last_seen: Some(t.last_seen),
552 wakeable: Some(can_push),
553 owner: None,
554 acks: false,
555 });
556 }
557 }
558 for t in tvs {
560 if !all.iter().any(|d| d.id == t.device) {
561 all.push(LinkDevice {
562 id: t.device,
563 name: t.name,
564 platform: t.platform,
565 linked: false,
566 state: None,
567 last_seen: Some(t.last_seen),
568 wakeable: Some(false),
569 owner: None,
570 acks: false,
571 tv_pairing: Some(t.pairing),
572 });
573 }
574 }
575 all.extend(self.shared_devices(username, &entries, can_push));
576 for e in ours.iter().filter(|e| e.wants_devices) {
577 let devices = all.iter().filter(|d| d.id != e.device).cloned().collect();
578 let _ = e.tx.send(LinkCommand::Devices { devices }.into());
579 }
580 }
581
582 fn shared_devices(&self, grantee: &str, entries: &[Entry], can_push: bool) -> Vec<LinkDevice> {
586 let grants: Vec<Grant> = self
587 .grants
588 .lock()
589 .iter()
590 .filter(|g| g.grantee == grantee)
591 .cloned()
592 .collect();
593 let mut out = Vec::new();
594 for g in grants {
595 let linked = entries
596 .iter()
597 .find(|e| e.device == g.device && e.info.username == g.owner);
598 match linked {
599 Some(e) => out.push(LinkDevice {
600 id: e.device.clone(),
601 name: e.info.name.clone(),
602 platform: e.info.platform.clone(),
603 linked: true,
604 state: e.info.reports.then(|| LinkState {
605 position_ms: e.info.position_ms(),
606 ..e.info.state.clone()
607 }),
608 last_seen: None,
609 wakeable: Some(can_push),
610 owner: Some(g.owner.clone()),
611 acks: e.info.acks,
612 tv_pairing: None,
613 }),
614 None => {
615 if let Some(t) = outbox::push_targets(Some(&g.owner))
616 .into_iter()
617 .find(|t| t.device == g.device)
618 {
619 out.push(LinkDevice {
620 id: t.device,
621 name: t.name,
622 platform: t.platform,
623 linked: false,
624 state: None,
625 last_seen: Some(t.last_seen),
626 wakeable: Some(can_push),
627 owner: Some(g.owner.clone()),
628 acks: false,
629 tv_pairing: None,
630 });
631 }
632 }
633 }
634 }
635 out
636 }
637
638 pub fn share(
642 &self,
643 owner: &str,
644 device: &str,
645 grantee: &str,
646 allow: bool,
647 ) -> Result<(), String> {
648 if grantee == owner {
649 return Err("a device is already its own account's".into());
650 }
651 let grant = Grant {
652 device: device.to_string(),
653 owner: owner.to_string(),
654 grantee: grantee.to_string(),
655 };
656 {
657 let mut grants = self.grants.lock();
658 grants.retain(|g| *g != grant);
659 if allow {
660 grants.push(grant.clone());
661 }
662 }
663 if allow {
664 outbox::save_grant(&grant);
665 } else {
666 outbox::drop_grant(&grant);
667 }
668 log::info!(
669 "share: {owner}'s {device} {} {grantee}",
670 if allow {
671 "shared with"
672 } else {
673 "no longer shared with"
674 }
675 );
676 self.send_shares(owner, device, None);
677 self.announce_to(grantee);
678 Ok(())
679 }
680
681 pub fn send_shares(&self, owner: &str, device: &str, error: Option<String>) {
684 let mut grantees: Vec<String> = self
685 .grants
686 .lock()
687 .iter()
688 .filter(|g| g.owner == owner && g.device == device)
689 .map(|g| g.grantee.clone())
690 .collect();
691 grantees.sort();
692 if let Some(e) = self
693 .entries
694 .lock()
695 .iter()
696 .find(|e| e.info.username == owner && e.device == device && e.wants_devices)
697 {
698 let accounts = outbox::accounts()
699 .into_iter()
700 .filter(|a| a != owner)
701 .collect();
702 let _ = e.tx.send(Envelope::from(LinkCommand::Shares {
703 grantees,
704 error,
705 accounts,
706 }));
707 }
708 }
709
710 pub fn seen_at(&self, device: &str, username: &str, addr: std::net::IpAddr) {
712 self.addresses
713 .lock()
714 .insert(device.to_string(), (username.to_string(), addr));
715 outbox::save_address(device, username, addr);
716 }
717
718 fn wake_owner(&self, username: &str, from: &str, to: &str) -> String {
725 if let Some(owner) = self.shared_owner(username, to) {
726 return owner;
727 }
728 let addresses = self.addresses.lock();
729 let here = addresses
730 .get(from)
731 .filter(|(user, _)| user == username)
732 .map(|(_, addr)| *addr);
733 match (here, addresses.get(to)) {
734 (Some(here), Some((owner, there))) if *there == here => owner.clone(),
735 _ => username.to_string(),
736 }
737 }
738
739 fn grantee_of(&self, owner: &str, from: &str, to: &str) -> Option<String> {
742 let to_user = self
743 .entries
744 .lock()
745 .iter()
746 .find(|e| e.device == to && e.info.username != owner)
747 .map(|e| e.info.username.clone())?;
748 self.grants
749 .lock()
750 .iter()
751 .any(|g| g.owner == owner && g.device == from && g.grantee == to_user)
752 .then_some(to_user)
753 }
754
755 fn shared_owner(&self, username: &str, to: &str) -> Option<String> {
758 self.grants
759 .lock()
760 .iter()
761 .find(|g| g.grantee == username && g.device == to)
762 .map(|g| g.owner.clone())
763 }
764
765 pub fn wake(&self, username: &str, from: &str, to: &str, notify: bool) {
771 let from_name = self
772 .list(Some(username))
773 .into_iter()
774 .find(|c| c.device == from)
775 .map_or_else(|| "Another device".to_string(), |c| c.name);
776 let owner = self.wake_owner(username, from, to);
777 if owner != username {
778 log::info!("wake: {to} is {owner}'s, woken for {username}");
779 }
780 let username = &owner;
781 if self.list(Some(username)).iter().any(|c| c.device == to) {
782 log::info!("wake: {to} is already linked");
783 return;
784 }
785 let Some(pusher) = crate::push::pusher() else {
786 log::info!("wake: push is off, so {to} cannot be woken");
787 return;
788 };
789 let Some(target) = outbox::push_targets(Some(username))
790 .into_iter()
791 .find(|t| t.device == to)
792 else {
793 log::info!("wake: {to} has given no push handle");
794 return;
795 };
796 let (push, how) = if notify {
797 log::info!("wake: {from_name} asks {to} to let it play");
798 (crate::push::Push::Summon, "notification")
799 } else {
800 (crate::push::Push::WakeNow, "wake push")
801 };
802 WOKEN.lock().insert(
803 (target.device.clone(), target.username.clone()),
804 (std::time::Instant::now(), how),
805 );
806 std::thread::spawn(move || {
807 let started = std::time::Instant::now();
808 deliver_push(pusher, &target, &push);
809 log::info!(
810 "wake: {how} for {} answered by the relay in {}ms",
811 target.name,
812 started.elapsed().as_millis()
813 );
814 });
815 }
816
817 pub fn forget(&self, username: &str, device: &str) -> Result<(), String> {
824 if let Some(owner) = self.shared_owner(username, device) {
827 return self.share(&owner, device, username, false);
828 }
829 if self.list(Some(username)).iter().any(|c| c.device == device) {
830 return Err(format!("{device} is linked; it would be back at once"));
831 }
832 outbox::forget_device(device, username);
833 log::info!("devices: {username} forgot {device}");
834 self.broadcast(
835 Some(username),
836 LinkCommand::Forgotten {
837 device: device.to_string(),
838 },
839 );
840 self.announce(username);
841 Ok(())
842 }
843
844 pub fn relay(
846 &self,
847 username: &str,
848 to: &str,
849 command: LinkCommand,
850 ) -> Result<ClientInfo, String> {
851 self.relay_from(username, None, to, command)
852 }
853
854 pub fn relay_from(
858 &self,
859 username: &str,
860 from: Option<&str>,
861 to: &str,
862 command: LinkCommand,
863 ) -> Result<ClientInfo, String> {
864 self.relay_acked(username, from, to, command, None)
865 }
866
867 pub fn relay_acked(
869 &self,
870 username: &str,
871 from: Option<&str>,
872 to: &str,
873 command: LinkCommand,
874 acking: Option<Acking>,
875 ) -> Result<ClientInfo, String> {
876 if !command.relayable() {
880 return Err("not a command".into());
881 }
882 if let Some(owner) = self.shared_owner(username, to) {
886 if !command.allowed_playback() {
887 return Err(format!("{to} is shared for playback only"));
888 }
889 let command = LinkCommand::Shared {
890 command: Box::new(command),
891 };
892 return self.send_with(Some(&owner), Some(to), command, acking);
893 }
894 if let Some(from) = from
898 && let Some(grantee) = self.grantee_of(username, from, to)
899 {
900 if !matches!(command, LinkCommand::Play { handoff: true, .. }) {
901 return Err(format!("{to} is another account's"));
902 }
903 let command = LinkCommand::Shared {
904 command: Box::new(command),
905 };
906 return self.send_with(Some(&grantee), Some(to), command, acking);
907 }
908 self.send_with(Some(username), Some(to), command, acking)
909 }
910
911 pub fn set_activity(&self, username: &str, watcher: &str, activity: Option<(String, String)>) {
914 let mut activities = self.activities.lock();
915 activities.retain(|a| !(a.username == username && a.watcher == watcher));
916 let Some((handle, target)) = activity else {
917 return;
918 };
919 activities.push(Activity {
920 username: username.to_string(),
921 watcher: watcher.to_string(),
922 target: target.clone(),
923 handle,
924 sent: None,
925 });
926 drop(activities);
927 self.update_activities(username, &target);
928 }
929
930 fn update_activities(&self, username: &str, target: &str) {
933 let Some(pusher) = crate::push::pusher() else {
934 return;
935 };
936 let owner = self
939 .shared_owner(username, target)
940 .unwrap_or_else(|| username.to_string());
941 let Some(info) = self
942 .list(Some(&owner))
943 .into_iter()
944 .find(|c| c.device == target)
945 else {
946 return;
947 };
948 let watchers: Vec<String> = self
949 .grants
950 .lock()
951 .iter()
952 .filter(|g| g.owner == owner && g.device == target)
953 .map(|g| g.grantee.clone())
954 .chain(std::iter::once(owner.clone()))
955 .collect();
956 let state = crate::push::activity_state(&info);
957 let mut due = Vec::new();
958 for a in self.activities.lock().iter_mut() {
959 if watchers.contains(&a.username)
960 && a.target == target
961 && a.sent
962 .as_ref()
963 .is_none_or(|s| crate::push::differs(&state, s))
964 {
965 a.sent = Some(state.clone());
966 due.push((a.handle.clone(), a.watcher.clone(), a.username.clone()));
967 }
968 }
969 if due.is_empty() {
970 return;
971 }
972 std::thread::spawn(move || {
973 for (handle, watcher, username) in due {
974 let push = crate::push::Push::Activity(state.clone());
975 match pusher.send(&handle, &push) {
976 crate::push::Outcome::Sent => {}
977 crate::push::Outcome::Gone => {
978 log::info!("push: a Live Activity on {watcher} has ended");
979 registry().set_activity(&username, &watcher, None);
980 }
981 crate::push::Outcome::Failed(e) => {
982 log::warn!("push: Live Activity on {watcher}: {e}");
983 }
984 }
985 }
986 });
987 }
988
989 pub fn list(&self, username: Option<&str>) -> Vec<ClientInfo> {
991 let mut out: Vec<ClientInfo> = self
992 .entries
993 .lock()
994 .iter()
995 .filter(|e| username.is_none_or(|u| e.info.username == u))
996 .map(|e| e.info.clone())
997 .collect();
998 out.sort_by_key(|c| std::cmp::Reverse(c.connected_at));
999 out
1000 }
1001
1002 pub fn send(
1007 &self,
1008 username: Option<&str>,
1009 id: Option<&str>,
1010 cmd: LinkCommand,
1011 ) -> Result<ClientInfo, String> {
1012 self.send_with(username, id, cmd, None)
1013 }
1014
1015 pub fn send_with(
1021 &self,
1022 username: Option<&str>,
1023 id: Option<&str>,
1024 cmd: LinkCommand,
1025 acking: Option<Acking>,
1026 ) -> Result<ClientInfo, String> {
1027 let clients = self.list(username);
1028 let target = match id {
1029 Some(id) => clients
1030 .iter()
1031 .find(|c| c.id == id || c.device == id || c.name.eq_ignore_ascii_case(id)),
1032 None if clients.is_empty() => None,
1033 None => Some(pick(&clients, chrono::Utc::now().timestamp())?),
1034 };
1035 let Some(target) = target else {
1037 let reached = reach_absent(username, id, &cmd, acking.as_ref().map(|a| a.id))
1038 .unwrap_or_else(|| {
1039 Err(match id {
1040 Some(id) => format!("no linked client {id}; see `clients`"),
1041 None => {
1042 "no koan app is linked to this server; open koan on the device".into()
1043 }
1044 })
1045 });
1046 if let (Ok(_), Some(acking)) = (&reached, acking) {
1047 (acking.reply)(Some(AckOutcome::Queued));
1048 }
1049 return reached;
1050 };
1051 let target = target.clone();
1052 let entries = self.entries.lock();
1053 let entry = entries
1054 .iter()
1055 .find(|e| e.info.id == target.id)
1056 .ok_or("that client has just gone")?;
1057 let (account, device) = (entry.info.username.clone(), entry.device.clone());
1058 let (ack, unanswered) = match acking {
1062 Some(acking) if entry.info.acks => {
1063 self.answers.lock().insert(
1064 (account.clone(), device.clone(), acking.id),
1065 Waiting {
1066 reply: acking.reply,
1067 received: false,
1068 timer: None,
1069 },
1070 );
1071 (Some(acking.id), None)
1072 }
1073 other => (None, other),
1074 };
1075 let sent = entry.tx.send(Envelope {
1076 command: cmd.clone(),
1077 ack,
1078 });
1079 drop(entries);
1080 if sent.is_err() {
1081 if let Some(w) = ack.and_then(|ack| self.answers.lock().remove(&(account, device, ack)))
1082 {
1083 w.answer(Some(AckOutcome::Failed {
1084 error: "its link has just gone".into(),
1085 }));
1086 }
1087 return Err("that client has just gone".to_string());
1088 }
1089 if let Some(acking) = unanswered {
1090 (acking.reply)(None);
1091 }
1092 if let Some(ack) = ack {
1093 self.watch(account, device, ack, cmd);
1094 }
1095 Ok(target)
1096 }
1097
1098 fn watch(&self, username: String, device: String, ack: u64, cmd: LinkCommand) {
1103 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
1104 return;
1105 };
1106 let key = (username.clone(), device.clone(), ack);
1107 let timer = runtime.spawn(async move {
1108 tokio::time::sleep(FIRST_LOOK).await;
1109 let (looking, looking_in) = (device.clone(), username.clone());
1110 let taken = tokio::task::spawn_blocking(move || {
1111 registry().look(&looking_in, &looking, ack, &cmd)
1112 })
1113 .await
1114 .unwrap_or(false);
1115 if taken {
1116 tokio::time::sleep(LONGEST_ANSWER.saturating_sub(FIRST_LOOK)).await;
1117 registry().give_up(&username, &device, ack);
1118 }
1119 });
1120 match self.answers.lock().get_mut(&key) {
1121 Some(w) => w.timer = Some(timer.abort_handle()),
1122 None => timer.abort(),
1124 }
1125 }
1126
1127 pub fn answered(&self, username: &str, device: &str, ack: u64, outcome: AckOutcome) {
1130 let key = (username.to_string(), device.to_string(), ack);
1131 let waiting = self.answers.lock().remove(&key);
1132 if let Some(w) = waiting {
1133 w.answer(Some(outcome));
1134 }
1135 }
1136
1137 pub fn received(&self, username: &str, device: &str, ack: u64) {
1140 let key = (username.to_string(), device.to_string(), ack);
1141 if let Some(w) = self.answers.lock().get_mut(&key) {
1142 w.received = true;
1143 }
1144 }
1145
1146 fn look(&self, username: &str, device: &str, ack: u64, cmd: &LinkCommand) -> bool {
1154 let key = (username.to_string(), device.to_string(), ack);
1155 let mut answers = self.answers.lock();
1156 match answers.get(&key) {
1157 None => return false,
1158 Some(w) if w.received => return true,
1159 Some(_) => {}
1160 }
1161 let w = answers.remove(&key).expect("just seen");
1162 drop(answers);
1163 log::info!("link: {device} has not taken a command; reaching it as away");
1164 let outcome = match reach_absent(Some(username), Some(device), cmd, Some(ack)) {
1165 Some(Ok(_)) => AckOutcome::Queued,
1166 _ => AckOutcome::Failed {
1167 error: format!("{device} did not take it"),
1168 },
1169 };
1170 w.answer(Some(outcome));
1171 false
1172 }
1173
1174 fn give_up(&self, username: &str, device: &str, ack: u64) {
1177 let key = (username.to_string(), device.to_string(), ack);
1178 let waiting = self.answers.lock().remove(&key);
1179 if let Some(w) = waiting {
1180 w.answer(None);
1181 }
1182 }
1183}
1184
1185impl Registry {
1186 pub fn add_order(&self, order: Order) {
1187 outbox::save_order(&order);
1188 self.orders.lock().push(order);
1189 }
1190
1191 pub fn orders(&self, username: Option<&str>) -> Vec<Order> {
1192 self.orders
1193 .lock()
1194 .iter()
1195 .filter(|o| username.is_none() || o.username.as_deref() == username)
1196 .cloned()
1197 .collect()
1198 }
1199
1200 pub fn cancel_order(&self, username: Option<&str>, id: &str) -> bool {
1201 let mut orders = self.orders.lock();
1202 let before = orders.len();
1203 orders
1204 .retain(|o| !(o.id == id && (username.is_none() || o.username.as_deref() == username)));
1205 let gone = orders.len() != before;
1206 if gone {
1207 outbox::drop_order(id);
1208 }
1209 gone
1210 }
1211
1212 fn done(&self, id: &str) {
1213 self.orders.lock().retain(|o| o.id != id);
1214 outbox::drop_order(id);
1215 }
1216
1217 pub fn fulfil_orders(&self, find: impl Fn(&Order) -> Option<Vec<String>>) {
1220 let now = chrono::Utc::now().timestamp();
1221 let pending: Vec<Order> = {
1222 let mut orders = self.orders.lock();
1223 for o in orders.iter().filter(|o| now - o.created_at >= ORDER_TTL) {
1224 outbox::drop_order(&o.id);
1225 }
1226 orders.retain(|o| now - o.created_at < ORDER_TTL);
1227 orders.clone()
1228 };
1229 for order in pending.into_iter().filter(|o| o.playlist.is_none()) {
1230 let Some(ids) = find(&order).filter(|ids| !ids.is_empty()) else {
1231 continue;
1232 };
1233 let track_ids = ids;
1234 let cmd = if order.play_next {
1235 LinkCommand::PlayNext { track_ids }
1236 } else {
1237 LinkCommand::Enqueue {
1238 track_ids,
1239 play: false,
1240 }
1241 };
1242 match self.send(order.username.as_deref(), order.client.as_deref(), cmd) {
1243 Ok(c) => {
1244 log::info!(
1245 "link: {} — {} arrived; queued on {}",
1246 order.artist,
1247 order.album,
1248 c.name
1249 );
1250 self.done(&order.id);
1251 }
1252 Err(e) => log::info!("link: {} — {} arrived but {e}", order.artist, order.album),
1254 }
1255 }
1256 }
1257}
1258
1259pub fn fulfil_from(db_path: &std::path::Path) {
1261 let registry = registry();
1262 if registry.orders.lock().is_empty() {
1263 return;
1264 }
1265 let Ok(db) = koan_core::db::connection::Database::open_existing(db_path) else {
1266 return;
1267 };
1268 let for_playlists: Vec<Order> = registry
1271 .orders
1272 .lock()
1273 .iter()
1274 .filter(|o| o.playlist.is_some())
1275 .cloned()
1276 .collect();
1277 let mut edited = false;
1278 for order in for_playlists {
1279 let Some(playlist) = order.playlist else {
1280 continue;
1281 };
1282 let Some(ids) = order_tracks(&db.conn, &order) else {
1283 continue;
1284 };
1285 match koan_core::db::queries::add_tracks(&db.conn, playlist, &ids) {
1286 Ok(_) => {
1287 if !cfg!(test) {
1289 koan_core::playlists::push_to_remote(playlist);
1290 }
1291 log::info!(
1292 "link: {} — {} arrived; added {} tracks to playlist {playlist}",
1293 order.artist,
1294 order.album,
1295 ids.len()
1296 );
1297 registry.done(&order.id);
1298 edited = true;
1299 }
1300 Err(e) => log::warn!("link: could not add to playlist {playlist}: {e}"),
1301 }
1302 }
1303 if edited {
1304 changed();
1305 }
1306 registry.fulfil_orders(|order| {
1307 let rows = order_tracks(&db.conn, order)?;
1308 queries::uids_in_order(&db.conn, UidKind::Track, &rows).ok()
1309 });
1310}
1311
1312fn order_tracks(conn: &rusqlite::Connection, order: &Order) -> Option<Vec<i64>> {
1315 let tracks = album_tracks(conn, &order.artist, &order.album)?;
1316 if order.titles.is_empty() {
1317 return Some(tracks.into_iter().map(|(id, _)| id).collect());
1318 }
1319 let picked: Vec<i64> = order
1320 .titles
1321 .iter()
1322 .filter_map(|want| {
1323 let want = want.to_lowercase();
1324 tracks
1325 .iter()
1326 .find(|(_, t)| t.to_lowercase().contains(&want))
1327 .map(|(id, _)| *id)
1328 })
1329 .collect();
1330 (!picked.is_empty()).then_some(picked)
1331}
1332
1333pub fn album_tracks(
1336 conn: &rusqlite::Connection,
1337 artist: &str,
1338 album: &str,
1339) -> Option<Vec<(i64, String)>> {
1340 let like = |s: &str| format!("%{}%", s.replace(['%', '_'], ""));
1341 let album_id: i64 = conn
1342 .query_row(
1343 "SELECT al.id FROM albums al JOIN artists a ON a.id = al.artist_id
1344 WHERE a.name LIKE ?1 COLLATE NOCASE AND al.title LIKE ?2 COLLATE NOCASE
1345 ORDER BY al.id DESC LIMIT 1",
1346 [like(artist), like(album)],
1347 |r| r.get(0),
1348 )
1349 .ok()?;
1350 let mut stmt = conn
1351 .prepare("SELECT id, title FROM tracks WHERE album_id = ?1 ORDER BY disc, track_number, id")
1352 .ok()?;
1353 let tracks = stmt
1354 .query_map([album_id], |r| Ok((r.get(0)?, r.get(1)?)))
1355 .ok()?
1356 .filter_map(Result::ok)
1357 .collect();
1358 Some(tracks)
1359}
1360
1361impl Registry {
1362 pub fn broadcast(&self, username: Option<&str>, cmd: LinkCommand) -> Vec<String> {
1365 let ids: Vec<String> = self.list(username).into_iter().map(|c| c.id).collect();
1366 let entries = self.entries.lock();
1367 entries
1368 .iter()
1369 .filter(|e| ids.contains(&e.info.id) && e.tx.send(cmd.clone().into()).is_ok())
1370 .map(|e| e.info.name.clone())
1371 .collect()
1372 }
1373}
1374
1375impl Registry {
1376 pub fn deliver(&self, username: Option<&str>, cmd: LinkCommand) -> (Vec<String>, Vec<String>) {
1381 let (sent, queued) = self.link_or_queue(username, &cmd);
1382 wake(&queued.iter().map(Absent::key).collect::<Vec<_>>());
1383 (sent, queued.into_iter().map(|q| q.name).collect())
1384 }
1385
1386 fn link_or_queue(
1387 &self,
1388 username: Option<&str>,
1389 cmd: &LinkCommand,
1390 ) -> (Vec<String>, Vec<Absent>) {
1391 let sent = self.broadcast(username, cmd.clone());
1392 let queued = outbox::queue_for_absent(username, &self.live(), cmd);
1393 (sent, queued)
1394 }
1395
1396 fn live(&self) -> Vec<(String, String)> {
1398 self.entries
1399 .lock()
1400 .iter()
1401 .map(|e| (e.device.clone(), e.info.username.clone()))
1402 .collect()
1403 }
1404}
1405
1406impl Absent {
1407 fn key(&self) -> (String, String) {
1408 (self.device.clone(), self.username.clone())
1409 }
1410}
1411
1412fn wake(devices: &[(String, String)]) {
1415 let Some(pusher) = crate::push::pusher() else {
1416 return;
1417 };
1418 let targets: Vec<outbox::PushTarget> = outbox::push_targets(None)
1419 .into_iter()
1420 .filter(|t| {
1421 devices
1422 .iter()
1423 .any(|(device, username)| *device == t.device && *username == t.username)
1424 })
1425 .collect();
1426 if targets.is_empty() {
1427 return;
1428 }
1429 std::thread::spawn(move || {
1430 for t in targets {
1431 deliver_push(pusher, &t, &crate::push::Push::Wake);
1432 }
1433 });
1434}
1435
1436fn reach_absent(
1445 username: Option<&str>,
1446 id: Option<&str>,
1447 cmd: &LinkCommand,
1448 ack: Option<u64>,
1449) -> Option<Result<ClientInfo, String>> {
1450 if cmd.live_only() {
1451 return None;
1452 }
1453 let envelope = Envelope {
1454 command: cmd.clone(),
1455 ack,
1456 };
1457 let pusher = crate::push::pusher()?;
1458 let target = outbox::push_targets(username)
1461 .into_iter()
1462 .find(|t| id.is_none_or(|id| t.device == id || t.name.eq_ignore_ascii_case(id)))?;
1463 let info = ClientInfo {
1464 id: target.device.clone(),
1465 device: target.device.clone(),
1466 name: target.name.clone(),
1467 platform: target.platform.clone(),
1468 username: target.username.clone(),
1469 connected_at: 0,
1470 state: LinkState::default(),
1471 last_played_at: None,
1472 state_at: 0,
1473 reports: false,
1474 notified: true,
1475 acks: false,
1476 };
1477 let asked = match cmd {
1479 LinkCommand::Shared { command } => command.as_ref(),
1480 cmd => cmd,
1481 };
1482 let verb = match asked {
1483 LinkCommand::Play { .. } | LinkCommand::JumpTo { .. } | LinkCommand::Resume => Some("Play"),
1484 _ => None,
1485 };
1486 let push = match verb {
1487 Some(verb) => {
1488 log::info!(
1489 "push: {verb} on {}: {}",
1490 target.name,
1491 outbox::describe(asked).unwrap_or_default()
1492 );
1493 crate::push::Push::Notify {
1494 claim: claims::keep(&target.device, &target.username, envelope),
1495 }
1496 }
1497 None => {
1498 outbox::queue_for(&target.device, &target.username, &envelope);
1499 crate::push::Push::Wake
1500 }
1501 };
1502 std::thread::spawn(move || deliver_push(pusher, &target, &push));
1503 Some(Ok(info))
1504}
1505
1506pub mod claims {
1511 use std::collections::HashMap;
1512 use std::time::{Duration, Instant};
1513
1514 use koan_core::remote::acks::Envelope;
1515 use parking_lot::Mutex;
1516
1517 const LIFE: Duration = Duration::from_secs(10 * 60);
1520
1521 struct Claim {
1522 device: String,
1523 username: String,
1524 envelope: Envelope,
1525 at: Instant,
1526 }
1527
1528 static CLAIMS: Mutex<Option<HashMap<String, Claim>>> = Mutex::new(None);
1529
1530 pub fn keep(device: &str, username: &str, envelope: Envelope) -> String {
1532 use base64::Engine;
1533 let mut raw = [0u8; 16];
1534 getrandom::fill(&mut raw).expect("system randomness");
1535 let id = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
1536 let mut claims = CLAIMS.lock();
1537 let claims = claims.get_or_insert_with(HashMap::new);
1538 claims.retain(|_, c| c.at.elapsed() < LIFE);
1539 claims.insert(
1540 id.clone(),
1541 Claim {
1542 device: device.to_string(),
1543 username: username.to_string(),
1544 envelope,
1545 at: Instant::now(),
1546 },
1547 );
1548 id
1549 }
1550
1551 pub fn take(device: &str, username: &str, id: &str) -> Option<Envelope> {
1553 let mut claims = CLAIMS.lock();
1554 let claims = claims.as_mut()?;
1555 let claim = claims.get(id)?;
1556 if claim.device != device || claim.username != username || claim.at.elapsed() >= LIFE {
1557 return None;
1558 }
1559 claims.remove(id).map(|c| c.envelope)
1560 }
1561}
1562
1563fn deliver_push(
1566 pusher: &crate::push::Pusher,
1567 target: &outbox::PushTarget,
1568 push: &crate::push::Push,
1569) {
1570 use crate::push::Outcome;
1571 match pusher.send(&target.handle, push) {
1572 Outcome::Sent => log::info!("push: sent to {}", target.name),
1573 Outcome::Gone => {
1574 log::info!(
1575 "push: {}'s handle no longer reaches it; forgotten",
1576 target.name
1577 );
1578 outbox::forget_push(&target.username, &target.device);
1579 }
1580 Outcome::Failed(e) => log::warn!("push: to {} failed: {e}", target.name),
1581 }
1582}
1583
1584type Woken = std::collections::HashMap<(String, String), (std::time::Instant, &'static str)>;
1588
1589static WOKEN: LazyLock<Mutex<Woken>> = LazyLock::new(Default::default);
1590
1591pub fn smart_activity(
1595 db: &koan_core::db::connection::Database,
1596 user: i64,
1597 fields: &[koan_core::smart::Field],
1598) {
1599 match koan_core::db::queries::smart::refresh_after_activity(&db.conn, user, fields) {
1600 Ok(moved) if !moved.is_empty() => changed(),
1601 Ok(_) => {}
1602 Err(e) => log::warn!("smart playlists not refreshed after activity: {e}"),
1603 }
1604}
1605
1606pub fn changed() {
1611 let (_, queued) = registry().link_or_queue(None, &LinkCommand::Sync { full: false });
1612 if queued.is_empty() || crate::push::pusher().is_none() {
1613 return;
1614 }
1615 let mut wakes = WAKES.lock();
1616 wakes.add(queued.iter().map(Absent::key), std::time::Instant::now());
1617 if !wakes.timer {
1618 wakes.timer = true;
1619 std::thread::spawn(send_wakes);
1620 }
1621}
1622
1623const QUIET: std::time::Duration = std::time::Duration::from_secs(30);
1627
1628const LONGEST_WAIT: std::time::Duration = std::time::Duration::from_secs(5 * 60);
1630
1631static WAKES: Mutex<Wakes> = Mutex::new(Wakes {
1632 pending: Vec::new(),
1633 timer: false,
1634});
1635
1636struct Wakes {
1639 pending: Vec<((String, String), std::time::Instant, std::time::Instant)>,
1641 timer: bool,
1643}
1644
1645impl Wakes {
1646 fn add(
1647 &mut self,
1648 devices: impl IntoIterator<Item = (String, String)>,
1649 now: std::time::Instant,
1650 ) {
1651 for key in devices {
1652 match self.pending.iter_mut().find(|(k, _, _)| *k == key) {
1653 Some((_, _, last)) => *last = now,
1654 None => self.pending.push((key, now, now)),
1655 }
1656 }
1657 }
1658
1659 fn due_at(first: std::time::Instant, last: std::time::Instant) -> std::time::Instant {
1660 (last + QUIET).min(first + LONGEST_WAIT)
1661 }
1662
1663 fn next(&self) -> Option<std::time::Instant> {
1665 self.pending
1666 .iter()
1667 .map(|(_, first, last)| Self::due_at(*first, *last))
1668 .min()
1669 }
1670
1671 fn take_due(&mut self, now: std::time::Instant) -> Vec<(String, String)> {
1673 let (due, waiting) = std::mem::take(&mut self.pending)
1674 .into_iter()
1675 .partition(|(_, first, last)| Self::due_at(*first, *last) <= now);
1676 self.pending = waiting;
1677 due.into_iter().map(|(key, _, _)| key).collect()
1678 }
1679}
1680
1681fn send_wakes() {
1684 loop {
1685 let (due, next) = {
1686 let mut wakes = WAKES.lock();
1687 let due = wakes.take_due(std::time::Instant::now());
1688 let next = wakes.next();
1689 if due.is_empty() && next.is_none() {
1690 wakes.timer = false;
1691 return;
1692 }
1693 (due, next)
1694 };
1695 let live = registry().live();
1696 let absent: Vec<(String, String)> = due.into_iter().filter(|d| !live.contains(d)).collect();
1697 if !absent.is_empty() {
1698 wake(&absent);
1699 }
1700 if let Some(next) = next {
1701 std::thread::sleep(next.saturating_duration_since(std::time::Instant::now()));
1702 }
1703 }
1704}
1705
1706pub fn changed_if_library_moved(conn: &rusqlite::Connection) {
1710 static LAST: parking_lot::Mutex<Option<(i64, i64, i64)>> = parking_lot::Mutex::new(None);
1711 let Ok(now) = conn.query_row(
1712 "SELECT (SELECT COUNT(*) FROM tracks), (SELECT COALESCE(MAX(id), 0) FROM tracks),
1713 (SELECT COUNT(*) FROM albums)",
1714 [],
1715 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1716 ) else {
1717 return;
1718 };
1719 let before = LAST.lock().replace(now);
1720 if before.is_some_and(|b| b != now) {
1721 changed();
1722 }
1723}
1724
1725mod outbox {
1728 use koan_core::db::queries;
1729 use koan_core::remote::link::LinkCommand;
1730
1731 const KEEP_SECS: i64 = 30 * 24 * 60 * 60;
1736
1737 fn db() -> Option<koan_core::db::pool::Handle<'static>> {
1742 if cfg!(test) {
1743 return None;
1744 }
1745 koan_core::db::pool::shared().get().ok()
1746 }
1747
1748 pub fn load_orders() -> Vec<super::Order> {
1749 let Some(db) = db() else { return Vec::new() };
1750 db.conn
1751 .prepare("SELECT body FROM link_orders ORDER BY created_at")
1752 .and_then(|mut s| {
1753 s.query_map([], |r| r.get::<_, String>(0))?
1754 .collect::<Result<Vec<_>, _>>()
1755 })
1756 .unwrap_or_default()
1757 .into_iter()
1758 .filter_map(|b| serde_json::from_str(&b).ok())
1759 .collect()
1760 }
1761
1762 pub fn save_order(order: &super::Order) {
1763 let (Some(db), Ok(body)) = (db(), serde_json::to_string(order)) else {
1764 return;
1765 };
1766 let _ = db.conn.execute(
1767 "INSERT OR REPLACE INTO link_orders (id, body, created_at) VALUES (?1, ?2, ?3)",
1768 rusqlite::params![order.id, body, order.created_at],
1769 );
1770 }
1771
1772 pub fn drop_order(id: &str) {
1773 if let Some(db) = db() {
1774 let _ = db
1775 .conn
1776 .execute("DELETE FROM link_orders WHERE id = ?1", [id]);
1777 }
1778 }
1779
1780 pub fn take_and_remember(
1781 username: &str,
1782 device: &str,
1783 name: &str,
1784 platform: &str,
1785 live: &[(String, String)],
1786 ) -> Vec<koan_core::remote::acks::Envelope> {
1787 let Some(db) = db() else { return Vec::new() };
1788 let now = chrono::Utc::now().timestamp();
1789 let _ = db.conn.execute(
1790 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES (?1, ?2, ?3, ?4, ?5)
1791 ON CONFLICT (device, username) DO UPDATE SET name = ?3, platform = ?4, last_seen = ?5",
1792 rusqlite::params![device, username, name, platform, now],
1793 );
1794 forget_stale(&db.conn, live, now);
1795 let waiting: Vec<(i64, String)> = db
1796 .conn
1797 .prepare("SELECT id, command FROM link_outbox WHERE device = ?1 AND username = ?2 ORDER BY id")
1798 .and_then(|mut s| {
1799 s.query_map([device, username], |r| Ok((r.get(0)?, r.get(1)?)))?
1800 .collect()
1801 })
1802 .unwrap_or_default();
1803 let _ = db.conn.execute(
1804 "DELETE FROM link_outbox WHERE device = ?1 AND username = ?2",
1805 [device, username],
1806 );
1807 if !waiting.is_empty() {
1808 log::info!("link: {} waiting commands for {name}", waiting.len());
1809 }
1810 waiting
1811 .into_iter()
1812 .filter_map(|(_, c)| serde_json::from_str(&c).ok())
1813 .collect()
1814 }
1815
1816 pub(super) fn forget_stale(conn: &rusqlite::Connection, live: &[(String, String)], now: i64) {
1819 touch_with(conn, live, now);
1820 let _ = conn.execute(
1821 "DELETE FROM link_outbox WHERE created_at < ?1",
1822 [now - KEEP_SECS],
1823 );
1824 let _ = conn.execute(
1827 "DELETE FROM link_handles WHERE (device, username) IN
1828 (SELECT device, username FROM link_devices WHERE last_seen < ?1)
1829 AND (device, username) NOT IN (SELECT device, username FROM link_tv)",
1830 [now - KEEP_SECS],
1831 );
1832 let _ = conn.execute(
1833 "DELETE FROM link_devices WHERE last_seen < ?1
1834 AND (device, username) NOT IN (SELECT device, username FROM link_tv)",
1835 [now - KEEP_SECS],
1836 );
1837 }
1838
1839 pub fn touch(devices: &[(String, String)]) {
1841 if let Some(db) = db() {
1842 touch_with(&db.conn, devices, chrono::Utc::now().timestamp());
1843 }
1844 }
1845
1846 fn touch_with(conn: &rusqlite::Connection, devices: &[(String, String)], now: i64) {
1847 for (device, username) in devices {
1848 let _ = conn.execute(
1849 "UPDATE link_devices SET last_seen = ?1 WHERE device = ?2 AND username = ?3",
1850 rusqlite::params![now, device, username],
1851 );
1852 }
1853 }
1854
1855 pub struct Absent {
1857 pub device: String,
1858 pub username: String,
1859 pub name: String,
1860 }
1861
1862 pub fn queue_for_absent(
1864 username: Option<&str>,
1865 live: &[(String, String)],
1866 cmd: &LinkCommand,
1867 ) -> Vec<Absent> {
1868 let Some(db) = db() else { return Vec::new() };
1869 let known: Vec<(String, String, String)> = db
1870 .conn
1871 .prepare("SELECT device, username, name FROM link_devices")
1872 .and_then(|mut s| {
1873 s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
1874 .collect()
1875 })
1876 .unwrap_or_default();
1877 let Ok(text) = serde_json::to_string(cmd) else {
1878 return Vec::new();
1879 };
1880 let absent: Vec<_> = known
1881 .into_iter()
1882 .filter(|(device, user, _)| {
1883 !username.is_some_and(|u| u != user)
1884 && !live.iter().any(|(d, u)| d == device && u == user)
1885 })
1886 .collect();
1887 if absent.is_empty() {
1888 return Vec::new();
1889 }
1890 let is_sync = matches!(cmd, LinkCommand::Sync { .. });
1891 let now = chrono::Utc::now().timestamp();
1892 koan_core::db::queries::atomically(&db.conn, || {
1895 let mut queued = Vec::new();
1896 for (device, user, name) in absent {
1897 if is_sync {
1898 let _ = db.conn.execute(
1900 "DELETE FROM link_outbox WHERE device = ?1 AND username = ?2 AND command LIKE '{\"type\":\"sync\"%'",
1901 [&device, &user],
1902 );
1903 }
1904 if db
1905 .conn
1906 .execute(
1907 "INSERT INTO link_outbox (device, username, command, created_at) VALUES (?1, ?2, ?3, ?4)",
1908 rusqlite::params![device, user, text, now],
1909 )
1910 .is_ok()
1911 {
1912 queued.push(Absent {
1913 device,
1914 username: user,
1915 name,
1916 });
1917 }
1918 }
1919 Ok::<_, rusqlite::Error>(queued)
1920 })
1921 .unwrap_or_default()
1922 }
1923
1924 pub struct TvPairing {
1926 pub device: String,
1927 pub name: String,
1928 pub platform: String,
1929 pub last_seen: i64,
1931 pub pairing: koan_core::remote::companion::Credential,
1932 }
1933
1934 #[derive(Clone)]
1936 pub struct PushTarget {
1937 pub device: String,
1938 pub username: String,
1939 pub name: String,
1940 pub platform: String,
1941 pub handle: String,
1943 pub last_seen: i64,
1945 }
1946
1947 pub fn queue_for(device: &str, username: &str, envelope: &koan_core::remote::acks::Envelope) {
1951 let (Some(db), Ok(text)) = (db(), serde_json::to_string(envelope)) else {
1952 return;
1953 };
1954 let _ = db.conn.execute(
1955 "INSERT INTO link_outbox (device, username, command, created_at) VALUES (?1, ?2, ?3, ?4)",
1956 rusqlite::params![device, username, text, chrono::Utc::now().timestamp()],
1957 );
1958 }
1959
1960 pub fn accounts() -> Vec<String> {
1964 let Some(db) = db() else { return Vec::new() };
1965 koan_core::db::queries::auth::list_users(&db.conn)
1966 .map(|users| users.into_iter().map(|u| u.username).collect())
1967 .unwrap_or_default()
1968 }
1969
1970 pub fn load_addresses() -> std::collections::HashMap<String, (String, std::net::IpAddr)> {
1973 let Some(db) = db() else {
1974 return Default::default();
1975 };
1976 load_addresses_in(&db.conn)
1977 }
1978
1979 pub(super) fn load_addresses_in(
1980 conn: &rusqlite::Connection,
1981 ) -> std::collections::HashMap<String, (String, std::net::IpAddr)> {
1982 conn.prepare(
1983 "SELECT device, username, addr FROM link_devices WHERE addr IS NOT NULL ORDER BY last_seen",
1984 )
1985 .and_then(|mut s| {
1986 s.query_map([], |r| {
1987 Ok((
1988 r.get::<_, String>(0)?,
1989 r.get::<_, String>(1)?,
1990 r.get::<_, String>(2)?,
1991 ))
1992 })?
1993 .collect::<Result<Vec<_>, _>>()
1994 })
1995 .unwrap_or_default()
1996 .into_iter()
1997 .filter_map(|(device, username, addr)| Some((device, (username, addr.parse().ok()?))))
1998 .collect()
1999 }
2000
2001 pub fn save_address(device: &str, username: &str, addr: std::net::IpAddr) {
2002 if let Some(db) = db() {
2003 save_address_in(&db.conn, device, username, addr);
2004 }
2005 }
2006
2007 pub(super) fn save_address_in(
2008 conn: &rusqlite::Connection,
2009 device: &str,
2010 username: &str,
2011 addr: std::net::IpAddr,
2012 ) {
2013 let _ = conn.execute(
2014 "UPDATE link_devices SET addr = ?1 WHERE device = ?2 AND username = ?3",
2015 rusqlite::params![addr.to_string(), device, username],
2016 );
2017 }
2018
2019 pub fn load_grants() -> Vec<super::Grant> {
2020 let Some(db) = db() else { return Vec::new() };
2021 db.conn
2022 .prepare("SELECT device, owner, grantee FROM link_grants ORDER BY created_at")
2023 .and_then(|mut s| {
2024 s.query_map([], |r| {
2025 Ok(super::Grant {
2026 device: r.get(0)?,
2027 owner: r.get(1)?,
2028 grantee: r.get(2)?,
2029 })
2030 })?
2031 .collect()
2032 })
2033 .unwrap_or_default()
2034 }
2035
2036 pub fn save_grant(g: &super::Grant) {
2037 if let Some(db) = db() {
2038 let _ = db.conn.execute(
2039 "INSERT OR IGNORE INTO link_grants (device, owner, grantee, created_at) VALUES (?1, ?2, ?3, ?4)",
2040 rusqlite::params![g.device, g.owner, g.grantee, chrono::Utc::now().timestamp()],
2041 );
2042 }
2043 }
2044
2045 pub fn drop_grant(g: &super::Grant) {
2046 if let Some(db) = db() {
2047 let _ = db.conn.execute(
2048 "DELETE FROM link_grants WHERE device = ?1 AND owner = ?2 AND grantee = ?3",
2049 [&g.device, &g.owner, &g.grantee],
2050 );
2051 }
2052 }
2053
2054 pub fn save_push(username: &str, device: &str, handle: &str) {
2055 let Some(db) = db() else { return };
2056 let _ = db.conn.execute(
2057 "INSERT INTO link_handles (device, username, handle, updated_at) VALUES (?1, ?2, ?3, ?4)
2058 ON CONFLICT (device, username) DO UPDATE SET handle = ?3, updated_at = ?4",
2059 rusqlite::params![device, username, handle, chrono::Utc::now().timestamp()],
2060 );
2061 }
2062
2063 pub fn is_tv(username: &str, device: &str) -> bool {
2065 let Some(db) = db() else { return false };
2066 db.conn
2067 .query_row(
2068 "SELECT 1 FROM link_devices WHERE device = ?1 AND username = ?2 AND platform = 'tvos'",
2069 [device, username],
2070 |_| Ok(()),
2071 )
2072 .is_ok()
2073 }
2074
2075 pub fn save_tv_pairing(
2076 username: &str,
2077 device: &str,
2078 pairing: Option<&koan_core::remote::companion::Credential>,
2079 ) {
2080 let Some(db) = db() else { return };
2081 let _ = match pairing.and_then(|p| serde_json::to_string(p).ok()) {
2082 Some(json) => db.conn.execute(
2083 "INSERT INTO link_tv (device, username, pairing, updated_at) VALUES (?1, ?2, ?3, ?4)
2084 ON CONFLICT (device, username) DO UPDATE SET pairing = ?3, updated_at = ?4",
2085 rusqlite::params![device, username, json, chrono::Utc::now().timestamp()],
2086 ),
2087 None => db.conn.execute(
2088 "DELETE FROM link_tv WHERE device = ?1 AND username = ?2",
2089 [device, username],
2090 ),
2091 };
2092 }
2093
2094 pub fn tv_pairings(username: &str) -> Vec<TvPairing> {
2096 let Some(db) = db() else { return Vec::new() };
2097 tv_pairings_in(&db.conn, username)
2098 }
2099
2100 pub(super) fn tv_pairings_in(conn: &rusqlite::Connection, username: &str) -> Vec<TvPairing> {
2101 conn.prepare(
2102 "SELECT t.device, d.name, d.platform, d.last_seen, t.pairing
2103 FROM link_tv t JOIN link_devices d ON d.device = t.device AND d.username = t.username
2104 WHERE t.username = ?1",
2105 )
2106 .and_then(|mut s| {
2107 s.query_map([username], |r| {
2108 Ok((
2109 r.get::<_, String>(0)?,
2110 r.get::<_, String>(1)?,
2111 r.get::<_, String>(2)?,
2112 r.get::<_, i64>(3)?,
2113 r.get::<_, String>(4)?,
2114 ))
2115 })?
2116 .collect::<Result<Vec<_>, _>>()
2117 })
2118 .unwrap_or_default()
2119 .into_iter()
2120 .filter_map(|(device, name, platform, last_seen, pairing)| {
2121 Some(TvPairing {
2122 device,
2123 name,
2124 platform,
2125 last_seen,
2126 pairing: serde_json::from_str(&pairing).ok()?,
2127 })
2128 })
2129 .collect()
2130 }
2131
2132 pub fn forget_push(username: &str, device: &str) {
2133 if let Some(db) = db() {
2134 let _ = db.conn.execute(
2135 "DELETE FROM link_handles WHERE device = ?1 AND username = ?2",
2136 [device, username],
2137 );
2138 }
2139 }
2140
2141 pub fn push_targets(username: Option<&str>) -> Vec<PushTarget> {
2143 let Some(db) = db() else { return Vec::new() };
2144 push_targets_in(&db.conn, username)
2145 }
2146
2147 pub(super) fn push_targets_in(
2148 conn: &rusqlite::Connection,
2149 username: Option<&str>,
2150 ) -> Vec<PushTarget> {
2151 conn
2152 .prepare(
2153 "SELECT p.device, p.username, d.name, d.platform, p.handle, d.last_seen
2154 FROM link_handles p JOIN link_devices d ON d.device = p.device AND d.username = p.username
2155 WHERE ?1 IS NULL OR p.username = ?1
2156 ORDER BY d.last_seen DESC",
2157 )
2158 .and_then(|mut s| {
2159 s.query_map([username], |r| {
2160 Ok(PushTarget {
2161 device: r.get(0)?,
2162 username: r.get(1)?,
2163 name: r.get(2)?,
2164 platform: r.get(3)?,
2165 handle: r.get(4)?,
2166 last_seen: r.get(5)?,
2167 })
2168 })?
2169 .collect()
2170 })
2171 .unwrap_or_default()
2172 }
2173
2174 pub fn forget_device(device: &str, username: &str) {
2177 if let Some(db) = db() {
2178 forget_device_in(&db.conn, device, username);
2179 }
2180 }
2181
2182 pub(super) fn forget_device_in(conn: &rusqlite::Connection, device: &str, username: &str) {
2183 for table in ["link_handles", "link_tv", "link_outbox", "link_devices"] {
2184 let _ = conn.execute(
2185 &format!("DELETE FROM {table} WHERE device = ?1 AND username = ?2"),
2186 [device, username],
2187 );
2188 }
2189 }
2190
2191 pub fn describe(cmd: &LinkCommand) -> Option<String> {
2194 let ids: Vec<&String> = match cmd {
2195 LinkCommand::Play { track_ids, .. }
2196 | LinkCommand::Enqueue { track_ids, .. }
2197 | LinkCommand::PlayNext { track_ids } => track_ids.iter().collect(),
2198 LinkCommand::JumpTo { track_id } => vec![track_id],
2199 _ => return None,
2200 };
2201 let db = db()?;
2202 let ids: Vec<i64> = ids
2203 .into_iter()
2204 .filter_map(|t| {
2205 queries::resolve_id(&db.conn, queries::UidKind::Track, t)
2206 .ok()
2207 .flatten()
2208 })
2209 .collect();
2210 let row = |id: i64| {
2211 db.conn
2212 .query_row(
2213 "SELECT t.title, COALESCE(a.name, ''), COALESCE(al.title, ''), t.album_id
2214 FROM tracks t LEFT JOIN artists a ON a.id = t.artist_id
2215 LEFT JOIN albums al ON al.id = t.album_id WHERE t.id = ?1",
2216 [id],
2217 |r| {
2218 Ok((
2219 r.get::<_, String>(0)?,
2220 r.get::<_, String>(1)?,
2221 r.get::<_, String>(2)?,
2222 r.get::<_, Option<i64>>(3)?,
2223 ))
2224 },
2225 )
2226 .ok()
2227 };
2228 let (title, artist, album, album_id) = row(*ids.first()?)?;
2229 let one_album = ids.len() > 1
2230 && album_id.is_some()
2231 && ids
2232 .iter()
2233 .all(|id| row(*id).is_some_and(|r| r.3 == album_id));
2234 Some(match (one_album, ids.len()) {
2235 (true, _) => format!("{album} — {artist}"),
2236 (false, 1) => format!("{title} — {artist}"),
2237 (false, n) => format!("{title} — {artist}, and {} more", n - 1),
2238 })
2239 }
2240}
2241
2242const RECENT: i64 = 6 * 60 * 60;
2245
2246fn pick(clients: &[ClientInfo], now: i64) -> Result<&ClientInfo, String> {
2247 if let Some(c) = clients.iter().find(|c| c.state.playing) {
2248 return Ok(c);
2249 }
2250 if let Some(c) = clients
2251 .iter()
2252 .filter(|c| c.last_played_at.is_some_and(|t| now - t < RECENT))
2253 .max_by_key(|c| c.last_played_at)
2254 {
2255 return Ok(c);
2256 }
2257 match clients {
2258 [] => Err("no koan app is linked to this server; open koan on the device".into()),
2259 [only] => Ok(only),
2260 several => Err(format!(
2261 "several koan apps are linked and none has played recently: {}. Ask which, then pass `client`",
2262 several
2263 .iter()
2264 .map(|c| format!("{} ({}, id {})", c.name, c.platform, c.device))
2265 .collect::<Vec<_>>()
2266 .join(", ")
2267 )),
2268 }
2269}
2270
2271#[cfg(test)]
2272mod tests {
2273
2274 #[test]
2279 fn a_quiet_link_that_took_the_command_is_kept_and_waited_for() {
2280 use koan_core::remote::acks::AckOutcome;
2281 let reg = Registry::default();
2282 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2283 reg.register("q", "mac", "macos", "dev-quiet", tx, false, true);
2284 let answers = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2285 let acking = |id: u64| {
2286 let answers = answers.clone();
2287 Acking {
2288 id,
2289 reply: Box::new(move |outcome| answers.lock().unwrap().push((id, outcome))),
2290 }
2291 };
2292
2293 reg.relay_acked("q", None, "dev-quiet", LinkCommand::Pause, Some(acking(1)))
2295 .unwrap();
2296 reg.received("q", "dev-quiet", 1);
2297 assert!(reg.look("q", "dev-quiet", 1, &LinkCommand::Pause));
2298 assert!(answers.lock().unwrap().is_empty(), "still waiting");
2299 reg.answered("q", "dev-quiet", 1, AckOutcome::Done);
2300 assert_eq!(
2301 answers.lock().unwrap().last(),
2302 Some(&(1, Some(AckOutcome::Done)))
2303 );
2304
2305 reg.relay_acked("q", None, "dev-quiet", LinkCommand::Pause, Some(acking(2)))
2308 .unwrap();
2309 assert!(!reg.look("q", "dev-quiet", 2, &LinkCommand::Pause));
2310 assert!(matches!(
2311 answers.lock().unwrap().last(),
2312 Some((2, Some(AckOutcome::Failed { .. })))
2313 ));
2314 assert_eq!(reg.list(Some("q")).len(), 1, "the link is not dropped");
2315 }
2316
2317 #[tokio::test]
2320 async fn an_answer_stops_its_timer() {
2321 let timer = tokio::spawn(tokio::time::sleep(std::time::Duration::from_secs(3600)));
2322 let handle = timer.abort_handle();
2323 let waiting = Waiting {
2324 reply: Box::new(|_| {}),
2325 received: true,
2326 timer: Some(timer.abort_handle()),
2327 };
2328 waiting.answer(Some(koan_core::remote::acks::AckOutcome::Done));
2329 assert!(timer.await.unwrap_err().is_cancelled());
2330 assert!(handle.is_finished());
2331 }
2332
2333 #[test]
2334 fn a_command_relayed_with_an_id_is_answered_once() {
2335 use koan_core::remote::acks::AckOutcome;
2336 let reg = Registry::default();
2337 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2338 reg.register("a", "phone", "ios", "dev-answers", tx, false, true);
2339 let (old_tx, mut old_rx) = tokio::sync::mpsc::unbounded_channel();
2340 reg.register("a", "mac", "macos", "dev-old", old_tx, false, false);
2341 let answers = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2342 let acking = |id: u64| {
2343 let answers = answers.clone();
2344 Acking {
2345 id,
2346 reply: Box::new(move |outcome| answers.lock().unwrap().push((id, outcome))),
2347 }
2348 };
2349
2350 reg.relay_acked(
2352 "a",
2353 None,
2354 "dev-answers",
2355 LinkCommand::Pause,
2356 Some(acking(7)),
2357 )
2358 .unwrap();
2359 assert_eq!(rx.try_recv().unwrap().ack, Some(7));
2360 let (b_tx, _b_rx) = tokio::sync::mpsc::unbounded_channel();
2362 reg.register("b", "phone", "ios", "dev-answers", b_tx, false, true);
2363 reg.received("b", "dev-answers", 7);
2364 reg.answered("b", "dev-answers", 7, AckOutcome::Done);
2365 assert!(
2366 answers.lock().unwrap().is_empty(),
2367 "b cannot answer a's command"
2368 );
2369 reg.answered("a", "dev-answers", 7, AckOutcome::Done);
2370 reg.answered("a", "dev-answers", 7, AckOutcome::Done);
2371
2372 reg.relay_acked("a", None, "dev-old", LinkCommand::Pause, Some(acking(8)))
2375 .unwrap();
2376 assert_eq!(old_rx.try_recv().unwrap().ack, None);
2377
2378 assert_eq!(
2379 *answers.lock().unwrap(),
2380 [(7, Some(AckOutcome::Done)), (8, None)]
2381 );
2382 }
2383
2384 #[test]
2385 fn a_watch_is_never_queued_and_is_renewed_when_the_target_relinks() {
2386 let reg = Registry::default();
2387 let watches = |rx: &mut tokio::sync::mpsc::UnboundedReceiver<Envelope>| {
2388 std::iter::from_fn(|| rx.try_recv().ok().map(|e| e.command))
2389 .filter(|c| matches!(c, LinkCommand::WatchLevels { .. }))
2390 .collect::<Vec<_>>()
2391 };
2392 assert!(
2394 reg.relay("rl", "rl-phone", LinkCommand::WatchLevels { on: true })
2395 .is_err()
2396 );
2397 reg.watch_levels("rl", "rl-mac", "rl-phone", true);
2399 let (tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2400 reg.register("rl", "phone", "ios", "rl-phone", tx, false, false);
2401 assert_eq!(
2402 watches(&mut phone),
2403 vec![LinkCommand::WatchLevels { on: true }],
2404 "told once, on linking, because it is watched now"
2405 );
2406 let (tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2408 reg.register("rl", "phone", "ios", "rl-phone", tx, false, false);
2409 assert_eq!(
2410 watches(&mut phone),
2411 vec![LinkCommand::WatchLevels { on: true }]
2412 );
2413 }
2414
2415 #[test]
2416 fn levels_reach_a_watcher_only_while_it_watches() {
2417 use koan_core::remote::levels::Frame;
2418 let reg = Registry::default();
2419 let (tx_mac, mut mac) = tokio::sync::mpsc::unbounded_channel();
2420 let (tx_phone, mut phone) = tokio::sync::mpsc::unbounded_channel();
2421 let mac_id = reg.register("lv", "mac", "macos", "lv-mac", tx_mac, false, false);
2422 reg.register("lv", "phone", "ios", "lv-phone", tx_phone, false, false);
2423 let levels = |rx: &mut tokio::sync::mpsc::UnboundedReceiver<Envelope>| {
2424 std::iter::from_fn(|| rx.try_recv().ok().map(|e| e.command))
2425 .filter(|c| {
2426 matches!(
2427 c,
2428 LinkCommand::Levels { .. } | LinkCommand::WatchLevels { .. }
2429 )
2430 })
2431 .collect::<Vec<_>>()
2432 };
2433 let f = Frame(1_000, 1, 2, 3);
2434
2435 reg.levels("lv", "lv-phone", f);
2436 assert!(levels(&mut mac).is_empty(), "nobody watching");
2437
2438 reg.watch_levels("lv", "lv-mac", "lv-phone", true);
2439 assert_eq!(
2440 levels(&mut phone),
2441 vec![LinkCommand::WatchLevels { on: true }]
2442 );
2443 reg.levels("lv", "lv-phone", f);
2444 assert_eq!(
2445 levels(&mut mac),
2446 vec![LinkCommand::Levels {
2447 from: "lv-phone".into(),
2448 f
2449 }]
2450 );
2451
2452 reg.unregister(&mac_id);
2454 assert_eq!(
2455 levels(&mut phone),
2456 vec![LinkCommand::WatchLevels { on: false }]
2457 );
2458 reg.watch_levels("lv", "lv-mac", "lv-phone", true);
2459 reg.watch_levels("lv", "lv-mac", "lv-phone", false);
2460 assert_eq!(
2461 levels(&mut phone),
2462 vec![
2463 LinkCommand::WatchLevels { on: true },
2464 LinkCommand::WatchLevels { on: false }
2465 ]
2466 );
2467 }
2468
2469 #[test]
2470 fn a_playlist_order_adds_the_named_tracks_once_they_arrive() {
2471 let dir = tempfile::tempdir().unwrap();
2472 let path = dir.path().join("koan.db");
2473 let db = koan_core::db::connection::Database::open(&path).unwrap();
2474 let playlist = koan_core::db::queries::create_playlist(
2475 &db.conn,
2476 koan_core::db::queries::LOCAL_USER,
2477 "cyberpunk",
2478 None,
2479 )
2480 .unwrap();
2481 let order = Order {
2482 id: "o1".into(),
2483 username: None,
2484 client: None,
2485 artist: "Perturbator".into(),
2486 album: "Dangerous Days".into(),
2487 play_next: false,
2488 playlist: Some(playlist),
2489 titles: vec!["Future Club".into()],
2490 created_at: chrono::Utc::now().timestamp(),
2491 };
2492 registry().add_order(order);
2493
2494 fulfil_from(&path);
2496 assert!(registry().orders(None).iter().any(|o| o.id == "o1"));
2497
2498 db.conn
2499 .execute_batch(
2500 "INSERT INTO artists (id, name) VALUES (1, 'Perturbator');
2501 INSERT INTO albums (id, title, artist_id) VALUES (1, 'Dangerous Days', 1);
2502 INSERT INTO tracks (id, title, album_id, artist_id, track_number, path) VALUES
2503 (1, 'Welcome Back', 1, 1, 1, '/1.flac'), (2, 'Future Club', 1, 1, 2, '/2.flac');",
2504 )
2505 .unwrap();
2506 fulfil_from(&path);
2507 let held: Vec<i64> = db
2508 .conn
2509 .prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = ?1")
2510 .unwrap()
2511 .query_map([playlist], |r| r.get(0))
2512 .unwrap()
2513 .collect::<Result<_, _>>()
2514 .unwrap();
2515 assert_eq!(held, [2]);
2516 assert!(!registry().orders(None).iter().any(|o| o.id == "o1"));
2517 }
2518
2519 use super::*;
2520
2521 #[test]
2522 fn a_claim_runs_once_and_only_on_its_device() {
2523 let envelope = Envelope {
2524 command: LinkCommand::Pause,
2525 ack: None,
2526 };
2527 let id = claims::keep("phone", "j", envelope);
2528 assert!(claims::take("tablet", "j", &id).is_none(), "another device");
2529 assert!(claims::take("phone", "k", &id).is_none(), "another account");
2530 assert_eq!(
2531 claims::take("phone", "j", &id).map(|e| e.command),
2532 Some(LinkCommand::Pause)
2533 );
2534 assert!(claims::take("phone", "j", &id).is_none(), "taken once");
2535 assert!(claims::take("phone", "j", "made-up").is_none());
2536 }
2537
2538 #[test]
2539 fn a_reconnect_replaces_the_device_and_commands_reach_it() {
2540 let reg = Registry::default();
2541 let (tx1, _rx1) = tokio::sync::mpsc::unbounded_channel();
2542 let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel();
2543 let (tx3, _rx3) = tokio::sync::mpsc::unbounded_channel();
2544 reg.register("j", "phone", "ios", "dev-1", tx1, false, false);
2545 let id = reg.register("j", "phone", "ios", "dev-1", tx2, false, false);
2546 reg.register("someone", "laptop", "macos", "dev-2", tx3, false, false);
2547
2548 assert_eq!(reg.list(Some("j")).len(), 1);
2549 assert_eq!(reg.list(None).len(), 2);
2550
2551 let sent = reg.send(Some("j"), None, LinkCommand::Pause).unwrap();
2552 assert_eq!(sent.id, id);
2553 assert_eq!(rx2.try_recv().unwrap().command, LinkCommand::Pause);
2554
2555 assert!(
2557 reg.send(Some("j"), Some("laptop"), LinkCommand::Pause)
2558 .is_err()
2559 );
2560
2561 reg.unregister(&id);
2562 assert!(reg.send(Some("j"), None, LinkCommand::Pause).is_err());
2563 }
2564
2565 #[test]
2566 fn disconnecting_an_account_closes_only_its_links() {
2567 let reg = Registry::default();
2568 let (tx1, mut rx1) = tokio::sync::mpsc::unbounded_channel();
2569 let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel();
2570 reg.register("j", "phone", "ios", "dev-1", tx1, false, false);
2571 reg.register("someone", "laptop", "macos", "dev-2", tx2, false, false);
2572
2573 reg.disconnect("j");
2574 assert!(reg.list(Some("j")).is_empty());
2575 assert!(matches!(
2577 rx1.try_recv(),
2578 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
2579 ));
2580 assert_eq!(reg.list(Some("someone")).len(), 1);
2581 assert!(matches!(
2582 rx2.try_recv(),
2583 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
2584 ));
2585 }
2586
2587 #[test]
2588 fn a_burst_of_changes_wakes_each_device_once_when_it_goes_quiet() {
2589 let t0 = std::time::Instant::now();
2590 let s = std::time::Duration::from_secs;
2591 let phone = || ("dev-1".to_string(), "j".to_string());
2592 let ipad = || ("dev-2".to_string(), "j".to_string());
2593 let mut wakes = Wakes {
2594 pending: Vec::new(),
2595 timer: false,
2596 };
2597
2598 wakes.add([phone()], t0);
2599 wakes.add([phone(), ipad()], t0 + s(10));
2600 wakes.add([phone()], t0 + s(20));
2601 assert_eq!(wakes.pending.len(), 2);
2602
2603 assert!(wakes.take_due(t0 + s(39)).is_empty());
2605 assert_eq!(wakes.take_due(t0 + s(40)), [ipad()]);
2606 assert_eq!(wakes.next(), Some(t0 + s(50)));
2607 assert_eq!(wakes.take_due(t0 + s(50)), [phone()]);
2608 assert_eq!(wakes.next(), None);
2609 }
2610
2611 #[test]
2612 fn a_library_that_never_goes_quiet_still_wakes_devices() {
2613 let t0 = std::time::Instant::now();
2614 let phone = || ("dev-1".to_string(), "j".to_string());
2615 let mut wakes = Wakes {
2616 pending: Vec::new(),
2617 timer: false,
2618 };
2619 let mut sent = 0;
2620 for i in 0..40 {
2621 let now = t0 + std::time::Duration::from_secs(i * 10);
2622 sent += wakes.take_due(now).len();
2623 wakes.add([phone()], now);
2624 }
2625 assert_eq!(sent, 1);
2628 assert_eq!(wakes.pending.len(), 1);
2629 }
2630
2631 #[test]
2632 fn the_device_playing_is_the_one_meant() {
2633 let reg = Registry::default();
2634 let (tx1, _rx1) = tokio::sync::mpsc::unbounded_channel();
2635 let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel();
2636 let mac = reg.register("j", "mac", "macos", "dev-1", tx1, false, false);
2637 let phone = reg.register("j", "phone", "ios", "dev-2", tx2, false, false);
2638
2639 let err = reg.send(Some("j"), None, LinkCommand::Pause).unwrap_err();
2641 assert!(err.contains("mac") && err.contains("phone"), "{err}");
2642
2643 reg.report(
2644 &phone,
2645 LinkState {
2646 playing: true,
2647 ..Default::default()
2648 },
2649 );
2650 assert_eq!(
2651 reg.send(Some("j"), None, LinkCommand::Pause).unwrap().id,
2652 phone
2653 );
2654 assert_eq!(rx2.try_recv().unwrap().command, LinkCommand::Pause);
2655
2656 reg.report(&phone, LinkState::default());
2658 assert_eq!(
2659 reg.send(Some("j"), None, LinkCommand::Pause).unwrap().id,
2660 phone
2661 );
2662 let _ = mac;
2663 }
2664
2665 fn drain(rx: &mut tokio::sync::mpsc::UnboundedReceiver<Envelope>) -> Vec<LinkCommand> {
2666 std::iter::from_fn(|| rx.try_recv().ok().map(|e| e.command)).collect()
2667 }
2668
2669 fn shared() -> (
2671 Registry,
2672 tokio::sync::mpsc::UnboundedReceiver<Envelope>,
2673 tokio::sync::mpsc::UnboundedReceiver<Envelope>,
2674 tokio::sync::mpsc::UnboundedReceiver<Envelope>,
2675 ) {
2676 let reg = Registry::default();
2677 let (phone_tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2678 let (mac_tx, mac) = tokio::sync::mpsc::unbounded_channel();
2679 let (k_tx, k) = tokio::sync::mpsc::unbounded_channel();
2680 let id = reg.register("j", "phone", "ios", "dev-phone", phone_tx, true, false);
2681 reg.register("j", "mac", "macos", "dev-mac", mac_tx, true, false);
2682 reg.register("k", "laptop", "macos", "dev-k", k_tx, true, false);
2683 reg.report(
2684 &id,
2685 LinkState {
2686 playing: true,
2687 title: Some("Roygbiv".into()),
2688 outputs: Some(Default::default()),
2689 ..Default::default()
2690 },
2691 );
2692 reg.share("j", "dev-phone", "k", true).unwrap();
2693 drain(&mut phone);
2694 (reg, phone, mac, k)
2695 }
2696
2697 #[test]
2698 fn a_grantee_sees_the_shared_device_and_nothing_else_of_the_owner() {
2699 let (_reg, _phone, _mac, mut k) = shared();
2700 let listed = drain(&mut k)
2701 .into_iter()
2702 .rev()
2703 .find_map(|c| match c {
2704 LinkCommand::Devices { devices } => Some(devices),
2705 _ => None,
2706 })
2707 .expect("told of it");
2708 assert_eq!(listed.len(), 1, "the phone, not the Mac");
2709 let phone = &listed[0];
2710 assert_eq!(phone.id, "dev-phone");
2711 assert_eq!(phone.owner.as_deref(), Some("j"));
2712 let state = phone.state.as_ref().expect("its state");
2713 assert_eq!(state.title.as_deref(), Some("Roygbiv"));
2714 assert!(
2715 state.outputs.is_some(),
2716 "outputs, to choose from: the output is in the playback set"
2717 );
2718 }
2719
2720 #[test]
2726 fn a_grantee_controls_playback_as_itself_and_nothing_of_the_owners_account() {
2727 let (reg, mut phone, mut mac, _k) = shared();
2728 let wrapped = |c: LinkCommand| LinkCommand::Shared {
2729 command: Box::new(c),
2730 };
2731 for cmd in [
2732 LinkCommand::Pause,
2733 LinkCommand::SetRendererVolume { volume: 40 },
2734 LinkCommand::SleepTimer {
2735 timer: Some(koan_core::player::state::SleepTimer::After { minutes: 30 }),
2736 },
2737 LinkCommand::HandOff { to: "dev-k".into() },
2738 ] {
2739 reg.relay("k", "dev-phone", cmd.clone()).unwrap();
2740 assert_eq!(drain(&mut phone), [wrapped(cmd)]);
2741 }
2742 for cmd in [
2743 LinkCommand::Sync { full: false },
2744 LinkCommand::Evict { track_ids: vec![] },
2745 LinkCommand::Shared {
2746 command: Box::new(LinkCommand::Pause),
2747 },
2748 ] {
2749 assert!(!cmd.allowed_playback());
2750 assert!(reg.relay("k", "dev-phone", cmd).is_err());
2751 }
2752 assert!(drain(&mut phone).is_empty());
2753 assert!(
2754 reg.relay("k", "dev-mac", LinkCommand::Pause).is_err(),
2755 "not shared, not reachable"
2756 );
2757 assert!(
2758 drain(&mut mac)
2759 .iter()
2760 .all(|c| matches!(c, LinkCommand::Devices { .. } | LinkCommand::Shares { .. })),
2761 "news, and no command"
2762 );
2763 assert!(reg.shared_owner("k", "dev-mac").is_none(), "nor wakeable");
2764 }
2765
2766 #[test]
2770 fn a_hand_off_runs_both_ways_across_a_grant_and_nothing_else_does() {
2771 let (reg, _phone, _mac, mut k) = shared();
2772 drain(&mut k);
2773 let play = LinkCommand::Play {
2774 track_ids: vec!["t".into()],
2775 start_at: 0,
2776 position_ms: 1000,
2777 paused: false,
2778 handoff: true,
2779 mode: koan_core::player::state::QueueMode::Keep,
2780 };
2781 reg.relay_from("j", Some("dev-phone"), "dev-k", play.clone())
2782 .unwrap();
2783 assert!(drain(&mut k).contains(&LinkCommand::Shared {
2784 command: Box::new(play.clone())
2785 }));
2786 assert!(
2787 reg.relay_from("j", Some("dev-phone"), "dev-k", LinkCommand::Pause)
2788 .is_err(),
2789 "the owner's phone does not command the grantee's devices"
2790 );
2791 assert!(
2792 reg.relay_from("j", Some("dev-mac"), "dev-k", play.clone())
2793 .is_err(),
2794 "only the shared device"
2795 );
2796 let not_a_hand_off = LinkCommand::Play {
2797 track_ids: vec!["t".into()],
2798 start_at: 0,
2799 position_ms: 0,
2800 paused: false,
2801 handoff: false,
2802 mode: koan_core::player::state::QueueMode::Keep,
2803 };
2804 assert!(
2805 reg.relay_from("j", Some("dev-phone"), "dev-k", not_a_hand_off)
2806 .is_err(),
2807 "a hand-off, not any play"
2808 );
2809 }
2810
2811 #[test]
2812 fn revoking_ends_control_at_once() {
2813 let (reg, mut phone, _mac, mut k) = shared();
2814 reg.share("j", "dev-phone", "k", false).unwrap();
2815 assert!(reg.relay("k", "dev-phone", LinkCommand::Pause).is_err());
2816 assert!(
2817 drain(&mut phone)
2818 .iter()
2819 .all(|c| !matches!(c, LinkCommand::Pause))
2820 );
2821 assert!(reg.shared_owner("k", "dev-phone").is_none());
2822 let listed = drain(&mut k)
2823 .into_iter()
2824 .rev()
2825 .find_map(|c| match c {
2826 LinkCommand::Devices { devices } => Some(devices),
2827 _ => None,
2828 })
2829 .expect("told it is gone");
2830 assert!(listed.is_empty());
2831 }
2832
2833 #[test]
2834 fn a_device_hears_whom_it_is_shared_with_every_time_it_links() {
2835 let reg = Registry::default();
2836 let (tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2837 reg.register("j", "phone", "ios", "dev-phone", tx, true, false);
2838 let first = drain(&mut phone);
2839 assert!(
2840 first.contains(&LinkCommand::Shares {
2841 grantees: vec![],
2842 error: None,
2843 accounts: vec![],
2844 }),
2845 "an empty list replaces one from another server"
2846 );
2847 reg.send_shares(
2848 "j",
2849 "dev-phone",
2850 Some("There is no account called x".into()),
2851 );
2852 assert!(drain(&mut phone).iter().any(
2853 |c| matches!(c, LinkCommand::Shares { error: Some(e), .. } if e.contains("no account"))
2854 ));
2855 }
2856
2857 #[test]
2858 fn the_owner_is_told_who_it_shares_with() {
2859 let reg = Registry::default();
2860 let (tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2861 reg.register("j", "phone", "ios", "dev-phone", tx, true, false);
2862 reg.share("j", "dev-phone", "k", true).unwrap();
2863 reg.share("j", "dev-phone", "m", true).unwrap();
2864 let last = drain(&mut phone).into_iter().rev().find_map(|c| match c {
2865 LinkCommand::Shares { grantees, .. } => Some(grantees),
2866 _ => None,
2867 });
2868 assert_eq!(last, Some(vec!["k".to_string(), "m".to_string()]));
2869 assert!(
2870 reg.share("j", "dev-phone", "j", true).is_err(),
2871 "not with itself"
2872 );
2873 }
2874
2875 #[test]
2879 fn a_device_is_woken_for_another_account_on_its_network_or_by_grant() {
2880 let reg = Registry::default();
2881 let home: std::net::IpAddr = "203.0.113.7".parse().unwrap();
2882 let away: std::net::IpAddr = "198.51.100.2".parse().unwrap();
2883 reg.seen_at("dev-ipad", "sarita", home);
2884 reg.seen_at("dev-phone", "admin", home);
2885 assert_eq!(
2886 reg.wake_owner("admin", "dev-phone", "dev-ipad"),
2887 "sarita",
2888 "same address: the iPad's own token"
2889 );
2890 reg.seen_at("dev-phone", "admin", away);
2891 assert_eq!(
2892 reg.wake_owner("admin", "dev-phone", "dev-ipad"),
2893 "admin",
2894 "elsewhere, with no grant: only admin's own, which it is not"
2895 );
2896 reg.share("sarita", "dev-ipad", "admin", true).unwrap();
2897 assert_eq!(
2898 reg.wake_owner("admin", "dev-phone", "dev-ipad"),
2899 "sarita",
2900 "shared: from anywhere"
2901 );
2902 reg.seen_at("dev-other", "mallory", home);
2904 assert_eq!(reg.wake_owner("admin", "dev-other", "dev-tv"), "admin");
2905 }
2906
2907 #[test]
2910 fn where_a_device_last_linked_from_survives_a_restart() {
2911 let conn = rusqlite::Connection::open_in_memory().unwrap();
2912 koan_core::db::schema::create_tables(&conn).unwrap();
2913 for (device, user, seen) in [("dev-ipad", "sarita", 1), ("dev-phone", "admin", 2)] {
2914 conn.execute(
2915 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES (?1, ?2, ?1, 'ios', ?3)",
2916 rusqlite::params![device, user, seen],
2917 )
2918 .unwrap();
2919 }
2920 let home: std::net::IpAddr = "203.0.113.7".parse().unwrap();
2921 outbox::save_address_in(&conn, "dev-ipad", "sarita", home);
2922 outbox::save_address_in(&conn, "dev-phone", "admin", home);
2923
2924 let reg = Registry::default();
2926 *reg.addresses.lock() = outbox::load_addresses_in(&conn);
2927 assert_eq!(
2928 reg.wake_owner("admin", "dev-phone", "dev-ipad"),
2929 "sarita",
2930 "still woken through its own account after the restart"
2931 );
2932 }
2933
2934 #[test]
2938 fn a_forgotten_device_is_dropped_by_the_accounts_devices() {
2939 let reg = Registry::default();
2940 let (mac_tx, mut mac) = tokio::sync::mpsc::unbounded_channel();
2941 let (other_tx, mut other) = tokio::sync::mpsc::unbounded_channel();
2942 reg.register("j", "mac", "macos", "dev-mac", mac_tx, true, false);
2943 reg.register("k", "laptop", "macos", "dev-k", other_tx, true, false);
2944 while mac.try_recv().is_ok() {}
2945 while other.try_recv().is_ok() {}
2946
2947 reg.forget("j", "dev-phone").unwrap();
2948 let heard: Vec<LinkCommand> =
2949 std::iter::from_fn(|| mac.try_recv().ok().map(|e| e.command)).collect();
2950 assert!(heard.contains(&LinkCommand::Forgotten {
2951 device: "dev-phone".into()
2952 }));
2953 assert!(
2954 std::iter::from_fn(|| other.try_recv().ok().map(|e| e.command))
2955 .all(|c| !matches!(c, LinkCommand::Forgotten { .. })),
2956 "another account hears nothing of it"
2957 );
2958 assert!(
2959 reg.forget("j", "dev-mac").is_err(),
2960 "linked: it would be back"
2961 );
2962 assert!(
2963 reg.relay(
2964 "j",
2965 "dev-mac",
2966 LinkCommand::Forgotten { device: "x".into() }
2967 )
2968 .is_err(),
2969 "news from the server, not a command a device may send"
2970 );
2971 }
2972
2973 #[test]
2976 fn forgetting_a_shared_device_declines_the_share() {
2977 let (reg, mut phone, _mac, mut k) = shared();
2978 drain(&mut k);
2979 reg.forget("k", "dev-phone").unwrap();
2980 assert!(reg.shared_owner("k", "dev-phone").is_none());
2981 assert!(
2982 drain(&mut phone)
2983 .iter()
2984 .any(|c| matches!(c, LinkCommand::Shares { grantees, .. } if grantees.is_empty()))
2985 );
2986 let listed = drain(&mut k).into_iter().rev().find_map(|c| match c {
2987 LinkCommand::Devices { devices } => Some(devices),
2988 _ => None,
2989 });
2990 assert_eq!(listed.map(|d| d.len()), Some(0));
2991 }
2992
2993 #[test]
2996 fn forgetting_a_device_stops_pushes_to_it_and_only_for_its_account() {
2997 let conn = rusqlite::Connection::open_in_memory().unwrap();
2998 koan_core::db::schema::create_tables(&conn).unwrap();
2999 for user in ["j", "k"] {
3000 conn.execute(
3001 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES ('dev-phone', ?1, 'phone', 'ios', 1)",
3002 [user],
3003 )
3004 .unwrap();
3005 conn.execute(
3006 "INSERT INTO link_handles (device, username, handle, updated_at) VALUES ('dev-phone', ?1, 'h', 1)",
3007 [user],
3008 )
3009 .unwrap();
3010 }
3011 outbox::forget_device_in(&conn, "dev-phone", "k");
3012 assert_eq!(
3013 outbox::push_targets_in(&conn, Some("j")).len(),
3014 1,
3015 "k forgetting its own leaves j's alone"
3016 );
3017 outbox::forget_device_in(&conn, "dev-phone", "j");
3018 assert!(outbox::push_targets_in(&conn, Some("j")).is_empty());
3019 assert!(outbox::push_targets_in(&conn, None).is_empty());
3020 }
3021
3022 #[test]
3023 fn a_device_hears_its_peers_and_commands_reach_them_by_device_id() {
3024 let reg = Registry::default();
3025 let (tx1, mut rx1) = tokio::sync::mpsc::unbounded_channel();
3026 let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel();
3027 let (tx3, mut rx3) = tokio::sync::mpsc::unbounded_channel();
3028 reg.register("j", "mac", "macos", "dev-mac", tx1, true, false);
3029 let phone = reg.register("j", "phone", "ios", "dev-phone", tx2, true, false);
3030 reg.register("someone", "laptop", "macos", "dev-other", tx3, true, false);
3031
3032 reg.report(
3033 &phone,
3034 LinkState {
3035 playing: true,
3036 title: Some("Roygbiv".into()),
3037 ..Default::default()
3038 },
3039 );
3040 let devices = drain(&mut rx1)
3041 .into_iter()
3042 .rev()
3043 .find_map(|c| match c {
3044 LinkCommand::Devices { devices } => Some(devices),
3045 _ => None,
3046 })
3047 .expect("the Mac is told of the phone");
3048 assert_eq!(devices.len(), 1, "not itself, not another account's");
3049 assert_eq!(devices[0].id, "dev-phone");
3050 assert_eq!(
3051 devices[0].state.as_ref().and_then(|s| s.title.as_deref()),
3052 Some("Roygbiv")
3053 );
3054 while rx3.try_recv().is_ok() {}
3055 assert!(rx3.try_recv().is_err());
3056
3057 while rx2.try_recv().is_ok() {}
3058 reg.relay("j", "dev-phone", LinkCommand::Pause).unwrap();
3059 assert_eq!(rx2.try_recv().unwrap().command, LinkCommand::Pause);
3060 assert!(
3061 reg.relay("someone", "dev-phone", LinkCommand::Pause)
3062 .is_err(),
3063 "another account cannot reach it"
3064 );
3065 assert!(
3066 reg.relay("j", "dev-phone", LinkCommand::Devices { devices: vec![] })
3067 .is_err()
3068 );
3069 }
3070
3071 #[test]
3072 fn a_device_linked_for_a_month_is_not_forgotten() {
3073 let conn = rusqlite::Connection::open_in_memory().unwrap();
3074 koan_core::db::schema::create_tables(&conn).unwrap();
3075 let now = 100 * 24 * 60 * 60;
3076 let long_ago = now - 40 * 24 * 60 * 60;
3077 for device in ["mac", "old-phone"] {
3078 conn.execute(
3079 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES (?1, 'j', ?1, 'ios', ?2)",
3080 rusqlite::params![device, long_ago],
3081 )
3082 .unwrap();
3083 conn.execute(
3084 "INSERT INTO link_handles (device, username, handle, updated_at) VALUES (?1, 'j', 'h', ?2)",
3085 rusqlite::params![device, long_ago],
3086 )
3087 .unwrap();
3088 }
3089 outbox::forget_stale(&conn, &[("mac".into(), "j".into())], now);
3090 let left = |table: &str| -> Vec<String> {
3091 conn.prepare(&format!("SELECT device FROM {table} ORDER BY device"))
3092 .unwrap()
3093 .query_map([], |r| r.get(0))
3094 .unwrap()
3095 .collect::<Result<_, _>>()
3096 .unwrap()
3097 };
3098 assert_eq!(left("link_devices"), ["mac"]);
3099 assert_eq!(left("link_handles"), ["mac"]);
3100 }
3101
3102 #[test]
3103 fn a_paired_apple_tv_is_kept_however_long_it_sleeps() {
3104 let conn = rusqlite::Connection::open_in_memory().unwrap();
3105 koan_core::db::schema::create_tables(&conn).unwrap();
3106 let now = 100 * 24 * 60 * 60;
3107 let long_ago = now - 90 * 24 * 60 * 60;
3108 for (device, platform) in [("tv", "tvos"), ("other-tv", "tvos")] {
3109 conn.execute(
3110 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES (?1, 'j', 'Living Room', ?2, ?3)",
3111 rusqlite::params![device, platform, long_ago],
3112 )
3113 .unwrap();
3114 }
3115 let pairing = koan_core::remote::companion::Credential {
3116 tv_id: "7A821767-01ED-442C-B4BB-6BDEA29109DA".into(),
3117 tv_key: "a2V5".into(),
3118 id: "ME".into(),
3119 seed: "c2VlZA==".into(),
3120 };
3121 conn.execute(
3122 "INSERT INTO link_tv (device, username, pairing, updated_at) VALUES ('tv', 'j', ?1, 0)",
3123 [serde_json::to_string(&pairing).unwrap()],
3124 )
3125 .unwrap();
3126 outbox::forget_stale(&conn, &[], now);
3127 let paired = outbox::tv_pairings_in(&conn, "j");
3128 assert_eq!(paired.len(), 1);
3129 assert_eq!(paired[0].device, "tv");
3130 assert_eq!(paired[0].name, "Living Room");
3131 assert_eq!(paired[0].pairing, pairing);
3132 assert!(outbox::tv_pairings_in(&conn, "someone").is_empty());
3133 let devices: i64 = conn
3134 .query_row("SELECT COUNT(*) FROM link_devices", [], |r| r.get(0))
3135 .unwrap();
3136 assert_eq!(devices, 1, "the unpaired TV is forgotten as before");
3137 }
3138}