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 token: String,
123 sandbox: bool,
124 sent: Option<crate::push::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, token: &str, sandbox: bool) {
341 outbox::save_push(username, device, token, sandbox);
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_live(&self, username: &str, device: &str, cmd: LinkCommand) {
454 if let Some(e) = self
455 .entries
456 .lock()
457 .iter()
458 .find(|e| e.info.username == username && e.device == device)
459 {
460 let _ = e.tx.send(cmd.into());
461 }
462 }
463
464 fn announce(&self, username: &str) {
468 self.announce_to(username);
469 let grantees: Vec<String> = {
471 let mut g: Vec<String> = self
472 .grants
473 .lock()
474 .iter()
475 .filter(|g| g.owner == username)
476 .map(|g| g.grantee.clone())
477 .collect();
478 g.sort();
479 g.dedup();
480 g
481 };
482 for grantee in grantees {
483 self.announce_to(&grantee);
484 }
485 }
486
487 fn announce_to(&self, username: &str) {
489 let listening = |e: &Entry| e.info.username == username && e.wants_devices;
490 if !self.entries.lock().iter().any(listening) {
491 return;
492 }
493 let asleep = outbox::push_targets(Some(username));
494 let tvs = outbox::tv_pairings(username);
495 let can_push = crate::push::pusher().is_some();
497 let entries = self.entries.lock();
498 let ours: Vec<&Entry> = entries
499 .iter()
500 .filter(|e| e.info.username == username)
501 .collect();
502 if !ours.iter().any(|e| e.wants_devices) {
503 return;
504 }
505 let mut all: Vec<LinkDevice> = ours
506 .iter()
507 .map(|e| LinkDevice {
508 id: e.device.clone(),
509 name: e.info.name.clone(),
510 platform: e.info.platform.clone(),
511 linked: true,
512 state: e.info.reports.then(|| LinkState {
513 position_ms: e.info.position_ms(),
514 ..e.info.state.clone()
515 }),
516 last_seen: None,
517 wakeable: Some(can_push && asleep.iter().any(|t| t.device == e.device)),
518 owner: None,
519 acks: e.info.acks,
520 tv_pairing: tvs
521 .iter()
522 .find(|t| t.device == e.device)
523 .map(|t| t.pairing.clone()),
524 })
525 .collect();
526 for t in asleep {
527 if !all.iter().any(|d| d.id == t.device) {
528 all.push(LinkDevice {
529 tv_pairing: tvs
530 .iter()
531 .find(|p| p.device == t.device)
532 .map(|p| p.pairing.clone()),
533 id: t.device,
534 name: t.name,
535 platform: t.platform,
536 linked: false,
537 state: None,
538 last_seen: Some(t.last_seen),
539 wakeable: Some(can_push),
540 owner: None,
541 acks: false,
542 });
543 }
544 }
545 for t in tvs {
547 if !all.iter().any(|d| d.id == t.device) {
548 all.push(LinkDevice {
549 id: t.device,
550 name: t.name,
551 platform: t.platform,
552 linked: false,
553 state: None,
554 last_seen: Some(t.last_seen),
555 wakeable: Some(false),
556 owner: None,
557 acks: false,
558 tv_pairing: Some(t.pairing),
559 });
560 }
561 }
562 all.extend(self.shared_devices(username, &entries, can_push));
563 for e in ours.iter().filter(|e| e.wants_devices) {
564 let devices = all.iter().filter(|d| d.id != e.device).cloned().collect();
565 let _ = e.tx.send(LinkCommand::Devices { devices }.into());
566 }
567 }
568
569 fn shared_devices(&self, grantee: &str, entries: &[Entry], can_push: bool) -> Vec<LinkDevice> {
573 let grants: Vec<Grant> = self
574 .grants
575 .lock()
576 .iter()
577 .filter(|g| g.grantee == grantee)
578 .cloned()
579 .collect();
580 let mut out = Vec::new();
581 for g in grants {
582 let linked = entries
583 .iter()
584 .find(|e| e.device == g.device && e.info.username == g.owner);
585 match linked {
586 Some(e) => out.push(LinkDevice {
587 id: e.device.clone(),
588 name: e.info.name.clone(),
589 platform: e.info.platform.clone(),
590 linked: true,
591 state: e.info.reports.then(|| LinkState {
592 position_ms: e.info.position_ms(),
593 ..e.info.state.clone()
594 }),
595 last_seen: None,
596 wakeable: Some(can_push),
597 owner: Some(g.owner.clone()),
598 acks: e.info.acks,
599 tv_pairing: None,
600 }),
601 None => {
602 if let Some(t) = outbox::push_targets(Some(&g.owner))
603 .into_iter()
604 .find(|t| t.device == g.device)
605 {
606 out.push(LinkDevice {
607 id: t.device,
608 name: t.name,
609 platform: t.platform,
610 linked: false,
611 state: None,
612 last_seen: Some(t.last_seen),
613 wakeable: Some(can_push),
614 owner: Some(g.owner.clone()),
615 acks: false,
616 tv_pairing: None,
617 });
618 }
619 }
620 }
621 }
622 out
623 }
624
625 pub fn share(
629 &self,
630 owner: &str,
631 device: &str,
632 grantee: &str,
633 allow: bool,
634 ) -> Result<(), String> {
635 if grantee == owner {
636 return Err("a device is already its own account's".into());
637 }
638 let grant = Grant {
639 device: device.to_string(),
640 owner: owner.to_string(),
641 grantee: grantee.to_string(),
642 };
643 {
644 let mut grants = self.grants.lock();
645 grants.retain(|g| *g != grant);
646 if allow {
647 grants.push(grant.clone());
648 }
649 }
650 if allow {
651 outbox::save_grant(&grant);
652 } else {
653 outbox::drop_grant(&grant);
654 }
655 log::info!(
656 "share: {owner}'s {device} {} {grantee}",
657 if allow {
658 "shared with"
659 } else {
660 "no longer shared with"
661 }
662 );
663 self.send_shares(owner, device, None);
664 self.announce_to(grantee);
665 Ok(())
666 }
667
668 pub fn send_shares(&self, owner: &str, device: &str, error: Option<String>) {
671 let mut grantees: Vec<String> = self
672 .grants
673 .lock()
674 .iter()
675 .filter(|g| g.owner == owner && g.device == device)
676 .map(|g| g.grantee.clone())
677 .collect();
678 grantees.sort();
679 if let Some(e) = self
680 .entries
681 .lock()
682 .iter()
683 .find(|e| e.info.username == owner && e.device == device && e.wants_devices)
684 {
685 let accounts = outbox::accounts()
686 .into_iter()
687 .filter(|a| a != owner)
688 .collect();
689 let _ = e.tx.send(Envelope::from(LinkCommand::Shares {
690 grantees,
691 error,
692 accounts,
693 }));
694 }
695 }
696
697 pub fn seen_at(&self, device: &str, username: &str, addr: std::net::IpAddr) {
699 self.addresses
700 .lock()
701 .insert(device.to_string(), (username.to_string(), addr));
702 outbox::save_address(device, username, addr);
703 }
704
705 fn wake_owner(&self, username: &str, from: &str, to: &str) -> String {
712 if let Some(owner) = self.shared_owner(username, to) {
713 return owner;
714 }
715 let addresses = self.addresses.lock();
716 let here = addresses
717 .get(from)
718 .filter(|(user, _)| user == username)
719 .map(|(_, addr)| *addr);
720 match (here, addresses.get(to)) {
721 (Some(here), Some((owner, there))) if *there == here => owner.clone(),
722 _ => username.to_string(),
723 }
724 }
725
726 fn grantee_of(&self, owner: &str, from: &str, to: &str) -> Option<String> {
729 let to_user = self
730 .entries
731 .lock()
732 .iter()
733 .find(|e| e.device == to && e.info.username != owner)
734 .map(|e| e.info.username.clone())?;
735 self.grants
736 .lock()
737 .iter()
738 .any(|g| g.owner == owner && g.device == from && g.grantee == to_user)
739 .then_some(to_user)
740 }
741
742 fn shared_owner(&self, username: &str, to: &str) -> Option<String> {
745 self.grants
746 .lock()
747 .iter()
748 .find(|g| g.grantee == username && g.device == to)
749 .map(|g| g.owner.clone())
750 }
751
752 pub fn wake(&self, username: &str, from: &str, to: &str, notify: bool) {
758 let from_name = self
759 .list(Some(username))
760 .into_iter()
761 .find(|c| c.device == from)
762 .map_or_else(|| "Another device".to_string(), |c| c.name);
763 let owner = self.wake_owner(username, from, to);
764 if owner != username {
765 log::info!("wake: {to} is {owner}'s, woken for {username}");
766 }
767 let username = &owner;
768 if self.list(Some(username)).iter().any(|c| c.device == to) {
769 log::info!("wake: {to} is already linked");
770 return;
771 }
772 let Some(pusher) = crate::push::pusher() else {
773 log::info!("wake: no push key, so {to} cannot be woken");
774 return;
775 };
776 let Some(target) = outbox::push_targets(Some(username))
777 .into_iter()
778 .find(|t| t.device == to)
779 else {
780 log::info!("wake: {to} has given no push token");
781 return;
782 };
783 let (push, how) = if notify {
784 (
785 crate::push::Push::Summon {
786 title: format!("{from_name} wants to play here"),
787 body: "Tap to open kōan and let it.".into(),
788 },
789 "notification",
790 )
791 } else {
792 (crate::push::Push::WakeNow, "wake push")
793 };
794 WOKEN.lock().insert(
795 (target.device.clone(), target.username.clone()),
796 (std::time::Instant::now(), how),
797 );
798 std::thread::spawn(move || {
799 let started = std::time::Instant::now();
800 deliver_push(pusher, &target, &push);
801 log::info!(
802 "wake: {how} for {} answered by APNs in {}ms",
803 target.name,
804 started.elapsed().as_millis()
805 );
806 });
807 }
808
809 pub fn forget(&self, username: &str, device: &str) -> Result<(), String> {
816 if let Some(owner) = self.shared_owner(username, device) {
819 return self.share(&owner, device, username, false);
820 }
821 if self.list(Some(username)).iter().any(|c| c.device == device) {
822 return Err(format!("{device} is linked; it would be back at once"));
823 }
824 outbox::forget_device(device, username);
825 log::info!("devices: {username} forgot {device}");
826 self.broadcast(
827 Some(username),
828 LinkCommand::Forgotten {
829 device: device.to_string(),
830 },
831 );
832 self.announce(username);
833 Ok(())
834 }
835
836 pub fn relay(
838 &self,
839 username: &str,
840 to: &str,
841 command: LinkCommand,
842 ) -> Result<ClientInfo, String> {
843 self.relay_from(username, None, to, command)
844 }
845
846 pub fn relay_from(
850 &self,
851 username: &str,
852 from: Option<&str>,
853 to: &str,
854 command: LinkCommand,
855 ) -> Result<ClientInfo, String> {
856 self.relay_acked(username, from, to, command, None)
857 }
858
859 pub fn relay_acked(
861 &self,
862 username: &str,
863 from: Option<&str>,
864 to: &str,
865 command: LinkCommand,
866 acking: Option<Acking>,
867 ) -> Result<ClientInfo, String> {
868 if !command.relayable() {
872 return Err("not a command".into());
873 }
874 if let Some(owner) = self.shared_owner(username, to) {
878 if !command.allowed_playback() {
879 return Err(format!("{to} is shared for playback only"));
880 }
881 let command = LinkCommand::Shared {
882 command: Box::new(command),
883 };
884 return self.send_with(Some(&owner), Some(to), command, acking);
885 }
886 if let Some(from) = from
890 && let Some(grantee) = self.grantee_of(username, from, to)
891 {
892 if !matches!(command, LinkCommand::Play { handoff: true, .. }) {
893 return Err(format!("{to} is another account's"));
894 }
895 let command = LinkCommand::Shared {
896 command: Box::new(command),
897 };
898 return self.send_with(Some(&grantee), Some(to), command, acking);
899 }
900 self.send_with(Some(username), Some(to), command, acking)
901 }
902
903 pub fn set_activity(
906 &self,
907 username: &str,
908 watcher: &str,
909 activity: Option<(String, String, bool)>,
910 ) {
911 let mut activities = self.activities.lock();
912 activities.retain(|a| !(a.username == username && a.watcher == watcher));
913 let Some((token, target, sandbox)) = activity else {
914 return;
915 };
916 activities.push(Activity {
917 username: username.to_string(),
918 watcher: watcher.to_string(),
919 target: target.clone(),
920 token,
921 sandbox,
922 sent: None,
923 });
924 drop(activities);
925 self.update_activities(username, &target);
926 }
927
928 fn update_activities(&self, username: &str, target: &str) {
931 let Some(pusher) = crate::push::pusher() else {
932 return;
933 };
934 let owner = self
937 .shared_owner(username, target)
938 .unwrap_or_else(|| username.to_string());
939 let Some(info) = self
940 .list(Some(&owner))
941 .into_iter()
942 .find(|c| c.device == target)
943 else {
944 return;
945 };
946 let watchers: Vec<String> = self
947 .grants
948 .lock()
949 .iter()
950 .filter(|g| g.owner == owner && g.device == target)
951 .map(|g| g.grantee.clone())
952 .chain(std::iter::once(owner.clone()))
953 .collect();
954 let state = crate::push::ActivityState::of(&info);
955 let mut due = Vec::new();
956 for a in self.activities.lock().iter_mut() {
957 if watchers.contains(&a.username)
958 && a.target == target
959 && a.sent.as_ref().is_none_or(|s| s.differs(&state))
960 {
961 a.sent = Some(state.clone());
962 due.push((
963 a.token.clone(),
964 a.sandbox,
965 a.watcher.clone(),
966 a.username.clone(),
967 ));
968 }
969 }
970 if due.is_empty() {
971 return;
972 }
973 std::thread::spawn(move || {
974 for (token, sandbox, watcher, username) in due {
975 let push = crate::push::Push::Activity(state.clone());
976 match pusher.send(&token, sandbox, &push) {
977 crate::push::Outcome::Sent => {}
978 crate::push::Outcome::Gone => {
979 log::info!("push: a Live Activity on {watcher} has ended");
980 registry().set_activity(&username, &watcher, None);
981 }
982 crate::push::Outcome::Failed(e) => {
983 log::warn!("push: Live Activity on {watcher}: {e}");
984 }
985 }
986 }
987 });
988 }
989
990 pub fn list(&self, username: Option<&str>) -> Vec<ClientInfo> {
992 let mut out: Vec<ClientInfo> = self
993 .entries
994 .lock()
995 .iter()
996 .filter(|e| username.is_none_or(|u| e.info.username == u))
997 .map(|e| e.info.clone())
998 .collect();
999 out.sort_by_key(|c| std::cmp::Reverse(c.connected_at));
1000 out
1001 }
1002
1003 pub fn send(
1008 &self,
1009 username: Option<&str>,
1010 id: Option<&str>,
1011 cmd: LinkCommand,
1012 ) -> Result<ClientInfo, String> {
1013 self.send_with(username, id, cmd, None)
1014 }
1015
1016 pub fn send_with(
1022 &self,
1023 username: Option<&str>,
1024 id: Option<&str>,
1025 cmd: LinkCommand,
1026 acking: Option<Acking>,
1027 ) -> Result<ClientInfo, String> {
1028 let clients = self.list(username);
1029 let target = match id {
1030 Some(id) => clients
1031 .iter()
1032 .find(|c| c.id == id || c.device == id || c.name.eq_ignore_ascii_case(id)),
1033 None if clients.is_empty() => None,
1034 None => Some(pick(&clients, chrono::Utc::now().timestamp())?),
1035 };
1036 let Some(target) = target else {
1038 let reached = reach_absent(username, id, &cmd, acking.as_ref().map(|a| a.id))
1039 .unwrap_or_else(|| {
1040 Err(match id {
1041 Some(id) => format!("no linked client {id}; see `clients`"),
1042 None => {
1043 "no koan app is linked to this server; open koan on the device".into()
1044 }
1045 })
1046 });
1047 if let (Ok(_), Some(acking)) = (&reached, acking) {
1048 (acking.reply)(Some(AckOutcome::Queued));
1049 }
1050 return reached;
1051 };
1052 let target = target.clone();
1053 let entries = self.entries.lock();
1054 let entry = entries
1055 .iter()
1056 .find(|e| e.info.id == target.id)
1057 .ok_or("that client has just gone")?;
1058 let (account, device) = (entry.info.username.clone(), entry.device.clone());
1059 let (ack, unanswered) = match acking {
1063 Some(acking) if entry.info.acks => {
1064 self.answers.lock().insert(
1065 (account.clone(), device.clone(), acking.id),
1066 Waiting {
1067 reply: acking.reply,
1068 received: false,
1069 timer: None,
1070 },
1071 );
1072 (Some(acking.id), None)
1073 }
1074 other => (None, other),
1075 };
1076 let sent = entry.tx.send(Envelope {
1077 command: cmd.clone(),
1078 ack,
1079 });
1080 drop(entries);
1081 if sent.is_err() {
1082 if let Some(w) = ack.and_then(|ack| self.answers.lock().remove(&(account, device, ack)))
1083 {
1084 w.answer(Some(AckOutcome::Failed {
1085 error: "its link has just gone".into(),
1086 }));
1087 }
1088 return Err("that client has just gone".to_string());
1089 }
1090 if let Some(acking) = unanswered {
1091 (acking.reply)(None);
1092 }
1093 if let Some(ack) = ack {
1094 self.watch(account, device, ack, cmd);
1095 }
1096 Ok(target)
1097 }
1098
1099 fn watch(&self, username: String, device: String, ack: u64, cmd: LinkCommand) {
1104 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
1105 return;
1106 };
1107 let key = (username.clone(), device.clone(), ack);
1108 let timer = runtime.spawn(async move {
1109 tokio::time::sleep(FIRST_LOOK).await;
1110 let (looking, looking_in) = (device.clone(), username.clone());
1111 let taken = tokio::task::spawn_blocking(move || {
1112 registry().look(&looking_in, &looking, ack, &cmd)
1113 })
1114 .await
1115 .unwrap_or(false);
1116 if taken {
1117 tokio::time::sleep(LONGEST_ANSWER.saturating_sub(FIRST_LOOK)).await;
1118 registry().give_up(&username, &device, ack);
1119 }
1120 });
1121 match self.answers.lock().get_mut(&key) {
1122 Some(w) => w.timer = Some(timer.abort_handle()),
1123 None => timer.abort(),
1125 }
1126 }
1127
1128 pub fn answered(&self, username: &str, device: &str, ack: u64, outcome: AckOutcome) {
1131 let key = (username.to_string(), device.to_string(), ack);
1132 let waiting = self.answers.lock().remove(&key);
1133 if let Some(w) = waiting {
1134 w.answer(Some(outcome));
1135 }
1136 }
1137
1138 pub fn received(&self, username: &str, device: &str, ack: u64) {
1141 let key = (username.to_string(), device.to_string(), ack);
1142 if let Some(w) = self.answers.lock().get_mut(&key) {
1143 w.received = true;
1144 }
1145 }
1146
1147 fn look(&self, username: &str, device: &str, ack: u64, cmd: &LinkCommand) -> bool {
1155 let key = (username.to_string(), device.to_string(), ack);
1156 let mut answers = self.answers.lock();
1157 match answers.get(&key) {
1158 None => return false,
1159 Some(w) if w.received => return true,
1160 Some(_) => {}
1161 }
1162 let w = answers.remove(&key).expect("just seen");
1163 drop(answers);
1164 log::info!("link: {device} has not taken a command; reaching it as away");
1165 let outcome = match reach_absent(Some(username), Some(device), cmd, Some(ack)) {
1166 Some(Ok(_)) => AckOutcome::Queued,
1167 _ => AckOutcome::Failed {
1168 error: format!("{device} did not take it"),
1169 },
1170 };
1171 w.answer(Some(outcome));
1172 false
1173 }
1174
1175 fn give_up(&self, username: &str, device: &str, ack: u64) {
1178 let key = (username.to_string(), device.to_string(), ack);
1179 let waiting = self.answers.lock().remove(&key);
1180 if let Some(w) = waiting {
1181 w.answer(None);
1182 }
1183 }
1184}
1185
1186impl Registry {
1187 pub fn add_order(&self, order: Order) {
1188 outbox::save_order(&order);
1189 self.orders.lock().push(order);
1190 }
1191
1192 pub fn orders(&self, username: Option<&str>) -> Vec<Order> {
1193 self.orders
1194 .lock()
1195 .iter()
1196 .filter(|o| username.is_none() || o.username.as_deref() == username)
1197 .cloned()
1198 .collect()
1199 }
1200
1201 pub fn cancel_order(&self, username: Option<&str>, id: &str) -> bool {
1202 let mut orders = self.orders.lock();
1203 let before = orders.len();
1204 orders
1205 .retain(|o| !(o.id == id && (username.is_none() || o.username.as_deref() == username)));
1206 let gone = orders.len() != before;
1207 if gone {
1208 outbox::drop_order(id);
1209 }
1210 gone
1211 }
1212
1213 fn done(&self, id: &str) {
1214 self.orders.lock().retain(|o| o.id != id);
1215 outbox::drop_order(id);
1216 }
1217
1218 pub fn fulfil_orders(&self, find: impl Fn(&Order) -> Option<Vec<String>>) {
1221 let now = chrono::Utc::now().timestamp();
1222 let pending: Vec<Order> = {
1223 let mut orders = self.orders.lock();
1224 for o in orders.iter().filter(|o| now - o.created_at >= ORDER_TTL) {
1225 outbox::drop_order(&o.id);
1226 }
1227 orders.retain(|o| now - o.created_at < ORDER_TTL);
1228 orders.clone()
1229 };
1230 for order in pending.into_iter().filter(|o| o.playlist.is_none()) {
1231 let Some(ids) = find(&order).filter(|ids| !ids.is_empty()) else {
1232 continue;
1233 };
1234 let track_ids = ids;
1235 let cmd = if order.play_next {
1236 LinkCommand::PlayNext { track_ids }
1237 } else {
1238 LinkCommand::Enqueue {
1239 track_ids,
1240 play: false,
1241 }
1242 };
1243 match self.send(order.username.as_deref(), order.client.as_deref(), cmd) {
1244 Ok(c) => {
1245 log::info!(
1246 "link: {} — {} arrived; queued on {}",
1247 order.artist,
1248 order.album,
1249 c.name
1250 );
1251 self.done(&order.id);
1252 }
1253 Err(e) => log::info!("link: {} — {} arrived but {e}", order.artist, order.album),
1255 }
1256 }
1257 }
1258}
1259
1260pub fn fulfil_from(db_path: &std::path::Path) {
1262 let registry = registry();
1263 if registry.orders.lock().is_empty() {
1264 return;
1265 }
1266 let Ok(db) = koan_core::db::connection::Database::open_existing(db_path) else {
1267 return;
1268 };
1269 let for_playlists: Vec<Order> = registry
1272 .orders
1273 .lock()
1274 .iter()
1275 .filter(|o| o.playlist.is_some())
1276 .cloned()
1277 .collect();
1278 let mut edited = false;
1279 for order in for_playlists {
1280 let Some(playlist) = order.playlist else {
1281 continue;
1282 };
1283 let Some(ids) = order_tracks(&db.conn, &order) else {
1284 continue;
1285 };
1286 match koan_core::db::queries::add_tracks(&db.conn, playlist, &ids) {
1287 Ok(_) => {
1288 if !cfg!(test) {
1290 koan_core::playlists::push_to_remote(playlist);
1291 }
1292 log::info!(
1293 "link: {} — {} arrived; added {} tracks to playlist {playlist}",
1294 order.artist,
1295 order.album,
1296 ids.len()
1297 );
1298 registry.done(&order.id);
1299 edited = true;
1300 }
1301 Err(e) => log::warn!("link: could not add to playlist {playlist}: {e}"),
1302 }
1303 }
1304 if edited {
1305 changed();
1306 }
1307 registry.fulfil_orders(|order| {
1308 let rows = order_tracks(&db.conn, order)?;
1309 queries::uids_in_order(&db.conn, UidKind::Track, &rows).ok()
1310 });
1311}
1312
1313fn order_tracks(conn: &rusqlite::Connection, order: &Order) -> Option<Vec<i64>> {
1316 let tracks = album_tracks(conn, &order.artist, &order.album)?;
1317 if order.titles.is_empty() {
1318 return Some(tracks.into_iter().map(|(id, _)| id).collect());
1319 }
1320 let picked: Vec<i64> = order
1321 .titles
1322 .iter()
1323 .filter_map(|want| {
1324 let want = want.to_lowercase();
1325 tracks
1326 .iter()
1327 .find(|(_, t)| t.to_lowercase().contains(&want))
1328 .map(|(id, _)| *id)
1329 })
1330 .collect();
1331 (!picked.is_empty()).then_some(picked)
1332}
1333
1334pub fn album_tracks(
1337 conn: &rusqlite::Connection,
1338 artist: &str,
1339 album: &str,
1340) -> Option<Vec<(i64, String)>> {
1341 let like = |s: &str| format!("%{}%", s.replace(['%', '_'], ""));
1342 let album_id: i64 = conn
1343 .query_row(
1344 "SELECT al.id FROM albums al JOIN artists a ON a.id = al.artist_id
1345 WHERE a.name LIKE ?1 COLLATE NOCASE AND al.title LIKE ?2 COLLATE NOCASE
1346 ORDER BY al.id DESC LIMIT 1",
1347 [like(artist), like(album)],
1348 |r| r.get(0),
1349 )
1350 .ok()?;
1351 let mut stmt = conn
1352 .prepare("SELECT id, title FROM tracks WHERE album_id = ?1 ORDER BY disc, track_number, id")
1353 .ok()?;
1354 let tracks = stmt
1355 .query_map([album_id], |r| Ok((r.get(0)?, r.get(1)?)))
1356 .ok()?
1357 .filter_map(Result::ok)
1358 .collect();
1359 Some(tracks)
1360}
1361
1362impl Registry {
1363 pub fn broadcast(&self, username: Option<&str>, cmd: LinkCommand) -> Vec<String> {
1366 let ids: Vec<String> = self.list(username).into_iter().map(|c| c.id).collect();
1367 let entries = self.entries.lock();
1368 entries
1369 .iter()
1370 .filter(|e| ids.contains(&e.info.id) && e.tx.send(cmd.clone().into()).is_ok())
1371 .map(|e| e.info.name.clone())
1372 .collect()
1373 }
1374}
1375
1376impl Registry {
1377 pub fn deliver(&self, username: Option<&str>, cmd: LinkCommand) -> (Vec<String>, Vec<String>) {
1382 let (sent, queued) = self.link_or_queue(username, &cmd);
1383 wake(&queued.iter().map(Absent::key).collect::<Vec<_>>());
1384 (sent, queued.into_iter().map(|q| q.name).collect())
1385 }
1386
1387 fn link_or_queue(
1388 &self,
1389 username: Option<&str>,
1390 cmd: &LinkCommand,
1391 ) -> (Vec<String>, Vec<Absent>) {
1392 let sent = self.broadcast(username, cmd.clone());
1393 let queued = outbox::queue_for_absent(username, &self.live(), cmd);
1394 (sent, queued)
1395 }
1396
1397 fn live(&self) -> Vec<(String, String)> {
1399 self.entries
1400 .lock()
1401 .iter()
1402 .map(|e| (e.device.clone(), e.info.username.clone()))
1403 .collect()
1404 }
1405}
1406
1407impl Absent {
1408 fn key(&self) -> (String, String) {
1409 (self.device.clone(), self.username.clone())
1410 }
1411}
1412
1413fn wake(devices: &[(String, String)]) {
1416 let Some(pusher) = crate::push::pusher() else {
1417 return;
1418 };
1419 let targets: Vec<outbox::PushTarget> = outbox::push_targets(None)
1420 .into_iter()
1421 .filter(|t| {
1422 devices
1423 .iter()
1424 .any(|(device, username)| *device == t.device && *username == t.username)
1425 })
1426 .collect();
1427 if targets.is_empty() {
1428 return;
1429 }
1430 std::thread::spawn(move || {
1431 for t in targets {
1432 deliver_push(pusher, &t, &crate::push::Push::Wake);
1433 }
1434 });
1435}
1436
1437fn reach_absent(
1446 username: Option<&str>,
1447 id: Option<&str>,
1448 cmd: &LinkCommand,
1449 ack: Option<u64>,
1450) -> Option<Result<ClientInfo, String>> {
1451 if cmd.live_only() {
1452 return None;
1453 }
1454 let envelope = Envelope {
1455 command: cmd.clone(),
1456 ack,
1457 };
1458 let pusher = crate::push::pusher()?;
1459 let target = outbox::push_targets(username)
1462 .into_iter()
1463 .find(|t| id.is_none_or(|id| t.device == id || t.name.eq_ignore_ascii_case(id)))?;
1464 let info = ClientInfo {
1465 id: target.device.clone(),
1466 device: target.device.clone(),
1467 name: target.name.clone(),
1468 platform: target.platform.clone(),
1469 username: target.username.clone(),
1470 connected_at: 0,
1471 state: LinkState::default(),
1472 last_played_at: None,
1473 state_at: 0,
1474 reports: false,
1475 notified: true,
1476 acks: false,
1477 };
1478 let asked = match cmd {
1480 LinkCommand::Shared { command } => command.as_ref(),
1481 cmd => cmd,
1482 };
1483 let verb = match asked {
1484 LinkCommand::Play { .. } | LinkCommand::JumpTo { .. } | LinkCommand::Resume => Some("Play"),
1485 _ => None,
1486 };
1487 let push = match verb {
1488 Some(verb) => crate::push::Push::Notify {
1489 title: format!("{verb} on {}", target.name),
1490 body: outbox::describe(asked).unwrap_or_else(|| "From your koan server".into()),
1491 command: serde_json::to_value(&envelope).ok()?,
1492 image: cover_track(asked)
1493 .and_then(outbox::track_row)
1494 .and_then(|t| pusher.cover_link(t)),
1495 },
1496 None => {
1497 outbox::queue_for(&target.device, &target.username, &envelope);
1498 crate::push::Push::Wake
1499 }
1500 };
1501 std::thread::spawn(move || deliver_push(pusher, &target, &push));
1502 Some(Ok(info))
1503}
1504
1505fn cover_track(cmd: &LinkCommand) -> Option<&str> {
1508 match cmd {
1509 LinkCommand::Play {
1510 track_ids,
1511 start_at,
1512 ..
1513 } => track_ids
1514 .get(*start_at as usize)
1515 .or(track_ids.first())
1516 .map(String::as_str),
1517 LinkCommand::JumpTo { track_id } => Some(track_id),
1518 _ => None,
1519 }
1520}
1521
1522fn deliver_push(
1524 pusher: &crate::push::Pusher,
1525 target: &outbox::PushTarget,
1526 push: &crate::push::Push,
1527) {
1528 use crate::push::Outcome;
1529 match pusher.send(&target.token, target.sandbox, push) {
1530 Outcome::Sent => log::info!("push: sent to {}", target.name),
1531 Outcome::Gone => {
1532 log::info!(
1533 "push: {}'s token is no longer valid; forgotten",
1534 target.name
1535 );
1536 outbox::forget_push(&target.username, &target.device);
1537 }
1538 Outcome::Failed(e) => log::warn!("push: to {} failed: {e}", target.name),
1539 }
1540}
1541
1542type Woken = std::collections::HashMap<(String, String), (std::time::Instant, &'static str)>;
1546
1547static WOKEN: LazyLock<Mutex<Woken>> = LazyLock::new(Default::default);
1548
1549pub fn smart_activity(
1553 db: &koan_core::db::connection::Database,
1554 user: i64,
1555 fields: &[koan_core::smart::Field],
1556) {
1557 match koan_core::db::queries::smart::refresh_after_activity(&db.conn, user, fields) {
1558 Ok(moved) if !moved.is_empty() => changed(),
1559 Ok(_) => {}
1560 Err(e) => log::warn!("smart playlists not refreshed after activity: {e}"),
1561 }
1562}
1563
1564pub fn changed() {
1569 let (_, queued) = registry().link_or_queue(None, &LinkCommand::Sync { full: false });
1570 if queued.is_empty() || crate::push::pusher().is_none() {
1571 return;
1572 }
1573 let mut wakes = WAKES.lock();
1574 wakes.add(queued.iter().map(Absent::key), std::time::Instant::now());
1575 if !wakes.timer {
1576 wakes.timer = true;
1577 std::thread::spawn(send_wakes);
1578 }
1579}
1580
1581const QUIET: std::time::Duration = std::time::Duration::from_secs(30);
1585
1586const LONGEST_WAIT: std::time::Duration = std::time::Duration::from_secs(5 * 60);
1588
1589static WAKES: Mutex<Wakes> = Mutex::new(Wakes {
1590 pending: Vec::new(),
1591 timer: false,
1592});
1593
1594struct Wakes {
1597 pending: Vec<((String, String), std::time::Instant, std::time::Instant)>,
1599 timer: bool,
1601}
1602
1603impl Wakes {
1604 fn add(
1605 &mut self,
1606 devices: impl IntoIterator<Item = (String, String)>,
1607 now: std::time::Instant,
1608 ) {
1609 for key in devices {
1610 match self.pending.iter_mut().find(|(k, _, _)| *k == key) {
1611 Some((_, _, last)) => *last = now,
1612 None => self.pending.push((key, now, now)),
1613 }
1614 }
1615 }
1616
1617 fn due_at(first: std::time::Instant, last: std::time::Instant) -> std::time::Instant {
1618 (last + QUIET).min(first + LONGEST_WAIT)
1619 }
1620
1621 fn next(&self) -> Option<std::time::Instant> {
1623 self.pending
1624 .iter()
1625 .map(|(_, first, last)| Self::due_at(*first, *last))
1626 .min()
1627 }
1628
1629 fn take_due(&mut self, now: std::time::Instant) -> Vec<(String, String)> {
1631 let (due, waiting) = std::mem::take(&mut self.pending)
1632 .into_iter()
1633 .partition(|(_, first, last)| Self::due_at(*first, *last) <= now);
1634 self.pending = waiting;
1635 due.into_iter().map(|(key, _, _)| key).collect()
1636 }
1637}
1638
1639fn send_wakes() {
1642 loop {
1643 let (due, next) = {
1644 let mut wakes = WAKES.lock();
1645 let due = wakes.take_due(std::time::Instant::now());
1646 let next = wakes.next();
1647 if due.is_empty() && next.is_none() {
1648 wakes.timer = false;
1649 return;
1650 }
1651 (due, next)
1652 };
1653 let live = registry().live();
1654 let absent: Vec<(String, String)> = due.into_iter().filter(|d| !live.contains(d)).collect();
1655 if !absent.is_empty() {
1656 wake(&absent);
1657 }
1658 if let Some(next) = next {
1659 std::thread::sleep(next.saturating_duration_since(std::time::Instant::now()));
1660 }
1661 }
1662}
1663
1664pub fn changed_if_library_moved(conn: &rusqlite::Connection) {
1668 static LAST: parking_lot::Mutex<Option<(i64, i64, i64)>> = parking_lot::Mutex::new(None);
1669 let Ok(now) = conn.query_row(
1670 "SELECT (SELECT COUNT(*) FROM tracks), (SELECT COALESCE(MAX(id), 0) FROM tracks),
1671 (SELECT COUNT(*) FROM albums)",
1672 [],
1673 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1674 ) else {
1675 return;
1676 };
1677 let before = LAST.lock().replace(now);
1678 if before.is_some_and(|b| b != now) {
1679 changed();
1680 }
1681}
1682
1683mod outbox {
1686 use koan_core::db::queries;
1687 use koan_core::remote::link::LinkCommand;
1688
1689 const KEEP_SECS: i64 = 30 * 24 * 60 * 60;
1694
1695 fn db() -> Option<koan_core::db::pool::Handle<'static>> {
1700 if cfg!(test) {
1701 return None;
1702 }
1703 koan_core::db::pool::shared().get().ok()
1704 }
1705
1706 pub fn load_orders() -> Vec<super::Order> {
1707 let Some(db) = db() else { return Vec::new() };
1708 db.conn
1709 .prepare("SELECT body FROM link_orders ORDER BY created_at")
1710 .and_then(|mut s| {
1711 s.query_map([], |r| r.get::<_, String>(0))?
1712 .collect::<Result<Vec<_>, _>>()
1713 })
1714 .unwrap_or_default()
1715 .into_iter()
1716 .filter_map(|b| serde_json::from_str(&b).ok())
1717 .collect()
1718 }
1719
1720 pub fn save_order(order: &super::Order) {
1721 let (Some(db), Ok(body)) = (db(), serde_json::to_string(order)) else {
1722 return;
1723 };
1724 let _ = db.conn.execute(
1725 "INSERT OR REPLACE INTO link_orders (id, body, created_at) VALUES (?1, ?2, ?3)",
1726 rusqlite::params![order.id, body, order.created_at],
1727 );
1728 }
1729
1730 pub fn drop_order(id: &str) {
1731 if let Some(db) = db() {
1732 let _ = db
1733 .conn
1734 .execute("DELETE FROM link_orders WHERE id = ?1", [id]);
1735 }
1736 }
1737
1738 pub fn take_and_remember(
1739 username: &str,
1740 device: &str,
1741 name: &str,
1742 platform: &str,
1743 live: &[(String, String)],
1744 ) -> Vec<koan_core::remote::acks::Envelope> {
1745 let Some(db) = db() else { return Vec::new() };
1746 let now = chrono::Utc::now().timestamp();
1747 let _ = db.conn.execute(
1748 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES (?1, ?2, ?3, ?4, ?5)
1749 ON CONFLICT (device, username) DO UPDATE SET name = ?3, platform = ?4, last_seen = ?5",
1750 rusqlite::params![device, username, name, platform, now],
1751 );
1752 forget_stale(&db.conn, live, now);
1753 let waiting: Vec<(i64, String)> = db
1754 .conn
1755 .prepare("SELECT id, command FROM link_outbox WHERE device = ?1 AND username = ?2 ORDER BY id")
1756 .and_then(|mut s| {
1757 s.query_map([device, username], |r| Ok((r.get(0)?, r.get(1)?)))?
1758 .collect()
1759 })
1760 .unwrap_or_default();
1761 let _ = db.conn.execute(
1762 "DELETE FROM link_outbox WHERE device = ?1 AND username = ?2",
1763 [device, username],
1764 );
1765 if !waiting.is_empty() {
1766 log::info!("link: {} waiting commands for {name}", waiting.len());
1767 }
1768 waiting
1769 .into_iter()
1770 .filter_map(|(_, c)| serde_json::from_str(&c).ok())
1771 .collect()
1772 }
1773
1774 pub(super) fn forget_stale(conn: &rusqlite::Connection, live: &[(String, String)], now: i64) {
1777 touch_with(conn, live, now);
1778 let _ = conn.execute(
1779 "DELETE FROM link_outbox WHERE created_at < ?1",
1780 [now - KEEP_SECS],
1781 );
1782 let _ = conn.execute(
1785 "DELETE FROM link_push WHERE (device, username) IN
1786 (SELECT device, username FROM link_devices WHERE last_seen < ?1)
1787 AND (device, username) NOT IN (SELECT device, username FROM link_tv)",
1788 [now - KEEP_SECS],
1789 );
1790 let _ = conn.execute(
1791 "DELETE FROM link_devices WHERE last_seen < ?1
1792 AND (device, username) NOT IN (SELECT device, username FROM link_tv)",
1793 [now - KEEP_SECS],
1794 );
1795 }
1796
1797 pub fn touch(devices: &[(String, String)]) {
1799 if let Some(db) = db() {
1800 touch_with(&db.conn, devices, chrono::Utc::now().timestamp());
1801 }
1802 }
1803
1804 fn touch_with(conn: &rusqlite::Connection, devices: &[(String, String)], now: i64) {
1805 for (device, username) in devices {
1806 let _ = conn.execute(
1807 "UPDATE link_devices SET last_seen = ?1 WHERE device = ?2 AND username = ?3",
1808 rusqlite::params![now, device, username],
1809 );
1810 }
1811 }
1812
1813 pub struct Absent {
1815 pub device: String,
1816 pub username: String,
1817 pub name: String,
1818 }
1819
1820 pub fn queue_for_absent(
1822 username: Option<&str>,
1823 live: &[(String, String)],
1824 cmd: &LinkCommand,
1825 ) -> Vec<Absent> {
1826 let Some(db) = db() else { return Vec::new() };
1827 let known: Vec<(String, String, String)> = db
1828 .conn
1829 .prepare("SELECT device, username, name FROM link_devices")
1830 .and_then(|mut s| {
1831 s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
1832 .collect()
1833 })
1834 .unwrap_or_default();
1835 let Ok(text) = serde_json::to_string(cmd) else {
1836 return Vec::new();
1837 };
1838 let absent: Vec<_> = known
1839 .into_iter()
1840 .filter(|(device, user, _)| {
1841 !username.is_some_and(|u| u != user)
1842 && !live.iter().any(|(d, u)| d == device && u == user)
1843 })
1844 .collect();
1845 if absent.is_empty() {
1846 return Vec::new();
1847 }
1848 let is_sync = matches!(cmd, LinkCommand::Sync { .. });
1849 let now = chrono::Utc::now().timestamp();
1850 koan_core::db::queries::atomically(&db.conn, || {
1853 let mut queued = Vec::new();
1854 for (device, user, name) in absent {
1855 if is_sync {
1856 let _ = db.conn.execute(
1858 "DELETE FROM link_outbox WHERE device = ?1 AND username = ?2 AND command LIKE '{\"type\":\"sync\"%'",
1859 [&device, &user],
1860 );
1861 }
1862 if db
1863 .conn
1864 .execute(
1865 "INSERT INTO link_outbox (device, username, command, created_at) VALUES (?1, ?2, ?3, ?4)",
1866 rusqlite::params![device, user, text, now],
1867 )
1868 .is_ok()
1869 {
1870 queued.push(Absent {
1871 device,
1872 username: user,
1873 name,
1874 });
1875 }
1876 }
1877 Ok::<_, rusqlite::Error>(queued)
1878 })
1879 .unwrap_or_default()
1880 }
1881
1882 pub struct TvPairing {
1884 pub device: String,
1885 pub name: String,
1886 pub platform: String,
1887 pub last_seen: i64,
1889 pub pairing: koan_core::remote::companion::Credential,
1890 }
1891
1892 #[derive(Clone)]
1894 pub struct PushTarget {
1895 pub device: String,
1896 pub username: String,
1897 pub name: String,
1898 pub platform: String,
1899 pub token: String,
1900 pub sandbox: bool,
1901 pub last_seen: i64,
1903 }
1904
1905 pub fn queue_for(device: &str, username: &str, envelope: &koan_core::remote::acks::Envelope) {
1909 let (Some(db), Ok(text)) = (db(), serde_json::to_string(envelope)) else {
1910 return;
1911 };
1912 let _ = db.conn.execute(
1913 "INSERT INTO link_outbox (device, username, command, created_at) VALUES (?1, ?2, ?3, ?4)",
1914 rusqlite::params![device, username, text, chrono::Utc::now().timestamp()],
1915 );
1916 }
1917
1918 pub fn accounts() -> Vec<String> {
1922 let Some(db) = db() else { return Vec::new() };
1923 koan_core::db::queries::auth::list_users(&db.conn)
1924 .map(|users| users.into_iter().map(|u| u.username).collect())
1925 .unwrap_or_default()
1926 }
1927
1928 pub fn load_addresses() -> std::collections::HashMap<String, (String, std::net::IpAddr)> {
1931 let Some(db) = db() else {
1932 return Default::default();
1933 };
1934 load_addresses_in(&db.conn)
1935 }
1936
1937 pub(super) fn load_addresses_in(
1938 conn: &rusqlite::Connection,
1939 ) -> std::collections::HashMap<String, (String, std::net::IpAddr)> {
1940 conn.prepare(
1941 "SELECT device, username, addr FROM link_devices WHERE addr IS NOT NULL ORDER BY last_seen",
1942 )
1943 .and_then(|mut s| {
1944 s.query_map([], |r| {
1945 Ok((
1946 r.get::<_, String>(0)?,
1947 r.get::<_, String>(1)?,
1948 r.get::<_, String>(2)?,
1949 ))
1950 })?
1951 .collect::<Result<Vec<_>, _>>()
1952 })
1953 .unwrap_or_default()
1954 .into_iter()
1955 .filter_map(|(device, username, addr)| Some((device, (username, addr.parse().ok()?))))
1956 .collect()
1957 }
1958
1959 pub fn save_address(device: &str, username: &str, addr: std::net::IpAddr) {
1960 if let Some(db) = db() {
1961 save_address_in(&db.conn, device, username, addr);
1962 }
1963 }
1964
1965 pub(super) fn save_address_in(
1966 conn: &rusqlite::Connection,
1967 device: &str,
1968 username: &str,
1969 addr: std::net::IpAddr,
1970 ) {
1971 let _ = conn.execute(
1972 "UPDATE link_devices SET addr = ?1 WHERE device = ?2 AND username = ?3",
1973 rusqlite::params![addr.to_string(), device, username],
1974 );
1975 }
1976
1977 pub fn load_grants() -> Vec<super::Grant> {
1978 let Some(db) = db() else { return Vec::new() };
1979 db.conn
1980 .prepare("SELECT device, owner, grantee FROM link_grants ORDER BY created_at")
1981 .and_then(|mut s| {
1982 s.query_map([], |r| {
1983 Ok(super::Grant {
1984 device: r.get(0)?,
1985 owner: r.get(1)?,
1986 grantee: r.get(2)?,
1987 })
1988 })?
1989 .collect()
1990 })
1991 .unwrap_or_default()
1992 }
1993
1994 pub fn save_grant(g: &super::Grant) {
1995 if let Some(db) = db() {
1996 let _ = db.conn.execute(
1997 "INSERT OR IGNORE INTO link_grants (device, owner, grantee, created_at) VALUES (?1, ?2, ?3, ?4)",
1998 rusqlite::params![g.device, g.owner, g.grantee, chrono::Utc::now().timestamp()],
1999 );
2000 }
2001 }
2002
2003 pub fn drop_grant(g: &super::Grant) {
2004 if let Some(db) = db() {
2005 let _ = db.conn.execute(
2006 "DELETE FROM link_grants WHERE device = ?1 AND owner = ?2 AND grantee = ?3",
2007 [&g.device, &g.owner, &g.grantee],
2008 );
2009 }
2010 }
2011
2012 pub fn save_push(username: &str, device: &str, token: &str, sandbox: bool) {
2013 let Some(db) = db() else { return };
2014 let _ = db.conn.execute(
2015 "INSERT INTO link_push (device, username, token, sandbox, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
2016 ON CONFLICT (device, username) DO UPDATE SET token = ?3, sandbox = ?4, updated_at = ?5",
2017 rusqlite::params![device, username, token, sandbox, chrono::Utc::now().timestamp()],
2018 );
2019 }
2020
2021 pub fn is_tv(username: &str, device: &str) -> bool {
2023 let Some(db) = db() else { return false };
2024 db.conn
2025 .query_row(
2026 "SELECT 1 FROM link_devices WHERE device = ?1 AND username = ?2 AND platform = 'tvos'",
2027 [device, username],
2028 |_| Ok(()),
2029 )
2030 .is_ok()
2031 }
2032
2033 pub fn save_tv_pairing(
2034 username: &str,
2035 device: &str,
2036 pairing: Option<&koan_core::remote::companion::Credential>,
2037 ) {
2038 let Some(db) = db() else { return };
2039 let _ = match pairing.and_then(|p| serde_json::to_string(p).ok()) {
2040 Some(json) => db.conn.execute(
2041 "INSERT INTO link_tv (device, username, pairing, updated_at) VALUES (?1, ?2, ?3, ?4)
2042 ON CONFLICT (device, username) DO UPDATE SET pairing = ?3, updated_at = ?4",
2043 rusqlite::params![device, username, json, chrono::Utc::now().timestamp()],
2044 ),
2045 None => db.conn.execute(
2046 "DELETE FROM link_tv WHERE device = ?1 AND username = ?2",
2047 [device, username],
2048 ),
2049 };
2050 }
2051
2052 pub fn tv_pairings(username: &str) -> Vec<TvPairing> {
2054 let Some(db) = db() else { return Vec::new() };
2055 tv_pairings_in(&db.conn, username)
2056 }
2057
2058 pub(super) fn tv_pairings_in(conn: &rusqlite::Connection, username: &str) -> Vec<TvPairing> {
2059 conn.prepare(
2060 "SELECT t.device, d.name, d.platform, d.last_seen, t.pairing
2061 FROM link_tv t JOIN link_devices d ON d.device = t.device AND d.username = t.username
2062 WHERE t.username = ?1",
2063 )
2064 .and_then(|mut s| {
2065 s.query_map([username], |r| {
2066 Ok((
2067 r.get::<_, String>(0)?,
2068 r.get::<_, String>(1)?,
2069 r.get::<_, String>(2)?,
2070 r.get::<_, i64>(3)?,
2071 r.get::<_, String>(4)?,
2072 ))
2073 })?
2074 .collect::<Result<Vec<_>, _>>()
2075 })
2076 .unwrap_or_default()
2077 .into_iter()
2078 .filter_map(|(device, name, platform, last_seen, pairing)| {
2079 Some(TvPairing {
2080 device,
2081 name,
2082 platform,
2083 last_seen,
2084 pairing: serde_json::from_str(&pairing).ok()?,
2085 })
2086 })
2087 .collect()
2088 }
2089
2090 pub fn forget_push(username: &str, device: &str) {
2091 if let Some(db) = db() {
2092 let _ = db.conn.execute(
2093 "DELETE FROM link_push WHERE device = ?1 AND username = ?2",
2094 [device, username],
2095 );
2096 }
2097 }
2098
2099 pub fn push_targets(username: Option<&str>) -> Vec<PushTarget> {
2101 let Some(db) = db() else { return Vec::new() };
2102 push_targets_in(&db.conn, username)
2103 }
2104
2105 pub(super) fn push_targets_in(
2106 conn: &rusqlite::Connection,
2107 username: Option<&str>,
2108 ) -> Vec<PushTarget> {
2109 conn
2110 .prepare(
2111 "SELECT p.device, p.username, d.name, d.platform, p.token, p.sandbox, d.last_seen
2112 FROM link_push p JOIN link_devices d ON d.device = p.device AND d.username = p.username
2113 WHERE ?1 IS NULL OR p.username = ?1
2114 ORDER BY d.last_seen DESC",
2115 )
2116 .and_then(|mut s| {
2117 s.query_map([username], |r| {
2118 Ok(PushTarget {
2119 device: r.get(0)?,
2120 username: r.get(1)?,
2121 name: r.get(2)?,
2122 platform: r.get(3)?,
2123 token: r.get(4)?,
2124 sandbox: r.get(5)?,
2125 last_seen: r.get(6)?,
2126 })
2127 })?
2128 .collect()
2129 })
2130 .unwrap_or_default()
2131 }
2132
2133 pub fn forget_device(device: &str, username: &str) {
2136 if let Some(db) = db() {
2137 forget_device_in(&db.conn, device, username);
2138 }
2139 }
2140
2141 pub(super) fn forget_device_in(conn: &rusqlite::Connection, device: &str, username: &str) {
2142 for table in ["link_push", "link_tv", "link_outbox", "link_devices"] {
2143 let _ = conn.execute(
2144 &format!("DELETE FROM {table} WHERE device = ?1 AND username = ?2"),
2145 [device, username],
2146 );
2147 }
2148 }
2149
2150 pub fn track_row(id: &str) -> Option<i64> {
2152 let db = db()?;
2153 queries::resolve_id(&db.conn, queries::UidKind::Track, id)
2154 .ok()
2155 .flatten()
2156 }
2157
2158 pub fn describe(cmd: &LinkCommand) -> Option<String> {
2161 let ids: Vec<&String> = match cmd {
2162 LinkCommand::Play { track_ids, .. }
2163 | LinkCommand::Enqueue { track_ids, .. }
2164 | LinkCommand::PlayNext { track_ids } => track_ids.iter().collect(),
2165 LinkCommand::JumpTo { track_id } => vec![track_id],
2166 _ => return None,
2167 };
2168 let db = db()?;
2169 let ids: Vec<i64> = ids
2170 .into_iter()
2171 .filter_map(|t| {
2172 queries::resolve_id(&db.conn, queries::UidKind::Track, t)
2173 .ok()
2174 .flatten()
2175 })
2176 .collect();
2177 let row = |id: i64| {
2178 db.conn
2179 .query_row(
2180 "SELECT t.title, COALESCE(a.name, ''), COALESCE(al.title, ''), t.album_id
2181 FROM tracks t LEFT JOIN artists a ON a.id = t.artist_id
2182 LEFT JOIN albums al ON al.id = t.album_id WHERE t.id = ?1",
2183 [id],
2184 |r| {
2185 Ok((
2186 r.get::<_, String>(0)?,
2187 r.get::<_, String>(1)?,
2188 r.get::<_, String>(2)?,
2189 r.get::<_, Option<i64>>(3)?,
2190 ))
2191 },
2192 )
2193 .ok()
2194 };
2195 let (title, artist, album, album_id) = row(*ids.first()?)?;
2196 let one_album = ids.len() > 1
2197 && album_id.is_some()
2198 && ids
2199 .iter()
2200 .all(|id| row(*id).is_some_and(|r| r.3 == album_id));
2201 Some(match (one_album, ids.len()) {
2202 (true, _) => format!("{album} — {artist}"),
2203 (false, 1) => format!("{title} — {artist}"),
2204 (false, n) => format!("{title} — {artist}, and {} more", n - 1),
2205 })
2206 }
2207}
2208
2209const RECENT: i64 = 6 * 60 * 60;
2212
2213fn pick(clients: &[ClientInfo], now: i64) -> Result<&ClientInfo, String> {
2214 if let Some(c) = clients.iter().find(|c| c.state.playing) {
2215 return Ok(c);
2216 }
2217 if let Some(c) = clients
2218 .iter()
2219 .filter(|c| c.last_played_at.is_some_and(|t| now - t < RECENT))
2220 .max_by_key(|c| c.last_played_at)
2221 {
2222 return Ok(c);
2223 }
2224 match clients {
2225 [] => Err("no koan app is linked to this server; open koan on the device".into()),
2226 [only] => Ok(only),
2227 several => Err(format!(
2228 "several koan apps are linked and none has played recently: {}. Ask which, then pass `client`",
2229 several
2230 .iter()
2231 .map(|c| format!("{} ({}, id {})", c.name, c.platform, c.device))
2232 .collect::<Vec<_>>()
2233 .join(", ")
2234 )),
2235 }
2236}
2237
2238#[cfg(test)]
2239mod tests {
2240
2241 #[test]
2246 fn a_quiet_link_that_took_the_command_is_kept_and_waited_for() {
2247 use koan_core::remote::acks::AckOutcome;
2248 let reg = Registry::default();
2249 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2250 reg.register("q", "mac", "macos", "dev-quiet", tx, false, true);
2251 let answers = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2252 let acking = |id: u64| {
2253 let answers = answers.clone();
2254 Acking {
2255 id,
2256 reply: Box::new(move |outcome| answers.lock().unwrap().push((id, outcome))),
2257 }
2258 };
2259
2260 reg.relay_acked("q", None, "dev-quiet", LinkCommand::Pause, Some(acking(1)))
2262 .unwrap();
2263 reg.received("q", "dev-quiet", 1);
2264 assert!(reg.look("q", "dev-quiet", 1, &LinkCommand::Pause));
2265 assert!(answers.lock().unwrap().is_empty(), "still waiting");
2266 reg.answered("q", "dev-quiet", 1, AckOutcome::Done);
2267 assert_eq!(
2268 answers.lock().unwrap().last(),
2269 Some(&(1, Some(AckOutcome::Done)))
2270 );
2271
2272 reg.relay_acked("q", None, "dev-quiet", LinkCommand::Pause, Some(acking(2)))
2275 .unwrap();
2276 assert!(!reg.look("q", "dev-quiet", 2, &LinkCommand::Pause));
2277 assert!(matches!(
2278 answers.lock().unwrap().last(),
2279 Some((2, Some(AckOutcome::Failed { .. })))
2280 ));
2281 assert_eq!(reg.list(Some("q")).len(), 1, "the link is not dropped");
2282 }
2283
2284 #[tokio::test]
2287 async fn an_answer_stops_its_timer() {
2288 let timer = tokio::spawn(tokio::time::sleep(std::time::Duration::from_secs(3600)));
2289 let handle = timer.abort_handle();
2290 let waiting = Waiting {
2291 reply: Box::new(|_| {}),
2292 received: true,
2293 timer: Some(timer.abort_handle()),
2294 };
2295 waiting.answer(Some(koan_core::remote::acks::AckOutcome::Done));
2296 assert!(timer.await.unwrap_err().is_cancelled());
2297 assert!(handle.is_finished());
2298 }
2299
2300 #[test]
2301 fn a_command_relayed_with_an_id_is_answered_once() {
2302 use koan_core::remote::acks::AckOutcome;
2303 let reg = Registry::default();
2304 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2305 reg.register("a", "phone", "ios", "dev-answers", tx, false, true);
2306 let (old_tx, mut old_rx) = tokio::sync::mpsc::unbounded_channel();
2307 reg.register("a", "mac", "macos", "dev-old", old_tx, false, false);
2308 let answers = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2309 let acking = |id: u64| {
2310 let answers = answers.clone();
2311 Acking {
2312 id,
2313 reply: Box::new(move |outcome| answers.lock().unwrap().push((id, outcome))),
2314 }
2315 };
2316
2317 reg.relay_acked(
2319 "a",
2320 None,
2321 "dev-answers",
2322 LinkCommand::Pause,
2323 Some(acking(7)),
2324 )
2325 .unwrap();
2326 assert_eq!(rx.try_recv().unwrap().ack, Some(7));
2327 let (b_tx, _b_rx) = tokio::sync::mpsc::unbounded_channel();
2329 reg.register("b", "phone", "ios", "dev-answers", b_tx, false, true);
2330 reg.received("b", "dev-answers", 7);
2331 reg.answered("b", "dev-answers", 7, AckOutcome::Done);
2332 assert!(
2333 answers.lock().unwrap().is_empty(),
2334 "b cannot answer a's command"
2335 );
2336 reg.answered("a", "dev-answers", 7, AckOutcome::Done);
2337 reg.answered("a", "dev-answers", 7, AckOutcome::Done);
2338
2339 reg.relay_acked("a", None, "dev-old", LinkCommand::Pause, Some(acking(8)))
2342 .unwrap();
2343 assert_eq!(old_rx.try_recv().unwrap().ack, None);
2344
2345 assert_eq!(
2346 *answers.lock().unwrap(),
2347 [(7, Some(AckOutcome::Done)), (8, None)]
2348 );
2349 }
2350
2351 #[test]
2352 fn a_watch_is_never_queued_and_is_renewed_when_the_target_relinks() {
2353 let reg = Registry::default();
2354 let watches = |rx: &mut tokio::sync::mpsc::UnboundedReceiver<Envelope>| {
2355 std::iter::from_fn(|| rx.try_recv().ok().map(|e| e.command))
2356 .filter(|c| matches!(c, LinkCommand::WatchLevels { .. }))
2357 .collect::<Vec<_>>()
2358 };
2359 assert!(
2361 reg.relay("rl", "rl-phone", LinkCommand::WatchLevels { on: true })
2362 .is_err()
2363 );
2364 reg.watch_levels("rl", "rl-mac", "rl-phone", true);
2366 let (tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2367 reg.register("rl", "phone", "ios", "rl-phone", tx, false, false);
2368 assert_eq!(
2369 watches(&mut phone),
2370 vec![LinkCommand::WatchLevels { on: true }],
2371 "told once, on linking, because it is watched now"
2372 );
2373 let (tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2375 reg.register("rl", "phone", "ios", "rl-phone", tx, false, false);
2376 assert_eq!(
2377 watches(&mut phone),
2378 vec![LinkCommand::WatchLevels { on: true }]
2379 );
2380 }
2381
2382 #[test]
2383 fn levels_reach_a_watcher_only_while_it_watches() {
2384 use koan_core::remote::levels::Frame;
2385 let reg = Registry::default();
2386 let (tx_mac, mut mac) = tokio::sync::mpsc::unbounded_channel();
2387 let (tx_phone, mut phone) = tokio::sync::mpsc::unbounded_channel();
2388 let mac_id = reg.register("lv", "mac", "macos", "lv-mac", tx_mac, false, false);
2389 reg.register("lv", "phone", "ios", "lv-phone", tx_phone, false, false);
2390 let levels = |rx: &mut tokio::sync::mpsc::UnboundedReceiver<Envelope>| {
2391 std::iter::from_fn(|| rx.try_recv().ok().map(|e| e.command))
2392 .filter(|c| {
2393 matches!(
2394 c,
2395 LinkCommand::Levels { .. } | LinkCommand::WatchLevels { .. }
2396 )
2397 })
2398 .collect::<Vec<_>>()
2399 };
2400 let f = Frame(1_000, 1, 2, 3);
2401
2402 reg.levels("lv", "lv-phone", f);
2403 assert!(levels(&mut mac).is_empty(), "nobody watching");
2404
2405 reg.watch_levels("lv", "lv-mac", "lv-phone", true);
2406 assert_eq!(
2407 levels(&mut phone),
2408 vec![LinkCommand::WatchLevels { on: true }]
2409 );
2410 reg.levels("lv", "lv-phone", f);
2411 assert_eq!(
2412 levels(&mut mac),
2413 vec![LinkCommand::Levels {
2414 from: "lv-phone".into(),
2415 f
2416 }]
2417 );
2418
2419 reg.unregister(&mac_id);
2421 assert_eq!(
2422 levels(&mut phone),
2423 vec![LinkCommand::WatchLevels { on: false }]
2424 );
2425 reg.watch_levels("lv", "lv-mac", "lv-phone", true);
2426 reg.watch_levels("lv", "lv-mac", "lv-phone", false);
2427 assert_eq!(
2428 levels(&mut phone),
2429 vec![
2430 LinkCommand::WatchLevels { on: true },
2431 LinkCommand::WatchLevels { on: false }
2432 ]
2433 );
2434 }
2435
2436 #[test]
2437 fn a_playlist_order_adds_the_named_tracks_once_they_arrive() {
2438 let dir = tempfile::tempdir().unwrap();
2439 let path = dir.path().join("koan.db");
2440 let db = koan_core::db::connection::Database::open(&path).unwrap();
2441 let playlist = koan_core::db::queries::create_playlist(
2442 &db.conn,
2443 koan_core::db::queries::LOCAL_USER,
2444 "cyberpunk",
2445 None,
2446 )
2447 .unwrap();
2448 let order = Order {
2449 id: "o1".into(),
2450 username: None,
2451 client: None,
2452 artist: "Perturbator".into(),
2453 album: "Dangerous Days".into(),
2454 play_next: false,
2455 playlist: Some(playlist),
2456 titles: vec!["Future Club".into()],
2457 created_at: chrono::Utc::now().timestamp(),
2458 };
2459 registry().add_order(order);
2460
2461 fulfil_from(&path);
2463 assert!(registry().orders(None).iter().any(|o| o.id == "o1"));
2464
2465 db.conn
2466 .execute_batch(
2467 "INSERT INTO artists (id, name) VALUES (1, 'Perturbator');
2468 INSERT INTO albums (id, title, artist_id) VALUES (1, 'Dangerous Days', 1);
2469 INSERT INTO tracks (id, title, album_id, artist_id, track_number, path) VALUES
2470 (1, 'Welcome Back', 1, 1, 1, '/1.flac'), (2, 'Future Club', 1, 1, 2, '/2.flac');",
2471 )
2472 .unwrap();
2473 fulfil_from(&path);
2474 let held: Vec<i64> = db
2475 .conn
2476 .prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = ?1")
2477 .unwrap()
2478 .query_map([playlist], |r| r.get(0))
2479 .unwrap()
2480 .collect::<Result<_, _>>()
2481 .unwrap();
2482 assert_eq!(held, [2]);
2483 assert!(!registry().orders(None).iter().any(|o| o.id == "o1"));
2484 }
2485
2486 use super::*;
2487
2488 #[test]
2489 fn a_notification_cover_is_named_by_the_uid_the_command_carries() {
2490 let uid = "0190a5b2-7c3d-7e4f-8a1b-2c3d4e5f6a7b".to_string();
2492 let play = LinkCommand::Play {
2493 track_ids: vec!["x".into(), uid.clone()],
2494 start_at: 1,
2495 position_ms: 0,
2496 paused: false,
2497 handoff: false,
2498 mode: koan_core::player::state::QueueMode::Keep,
2499 };
2500 assert_eq!(cover_track(&play), Some(uid.as_str()));
2501 }
2502
2503 #[test]
2504 fn a_reconnect_replaces_the_device_and_commands_reach_it() {
2505 let reg = Registry::default();
2506 let (tx1, _rx1) = tokio::sync::mpsc::unbounded_channel();
2507 let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel();
2508 let (tx3, _rx3) = tokio::sync::mpsc::unbounded_channel();
2509 reg.register("j", "phone", "ios", "dev-1", tx1, false, false);
2510 let id = reg.register("j", "phone", "ios", "dev-1", tx2, false, false);
2511 reg.register("someone", "laptop", "macos", "dev-2", tx3, false, false);
2512
2513 assert_eq!(reg.list(Some("j")).len(), 1);
2514 assert_eq!(reg.list(None).len(), 2);
2515
2516 let sent = reg.send(Some("j"), None, LinkCommand::Pause).unwrap();
2517 assert_eq!(sent.id, id);
2518 assert_eq!(rx2.try_recv().unwrap().command, LinkCommand::Pause);
2519
2520 assert!(
2522 reg.send(Some("j"), Some("laptop"), LinkCommand::Pause)
2523 .is_err()
2524 );
2525
2526 reg.unregister(&id);
2527 assert!(reg.send(Some("j"), None, LinkCommand::Pause).is_err());
2528 }
2529
2530 #[test]
2531 fn disconnecting_an_account_closes_only_its_links() {
2532 let reg = Registry::default();
2533 let (tx1, mut rx1) = tokio::sync::mpsc::unbounded_channel();
2534 let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel();
2535 reg.register("j", "phone", "ios", "dev-1", tx1, false, false);
2536 reg.register("someone", "laptop", "macos", "dev-2", tx2, false, false);
2537
2538 reg.disconnect("j");
2539 assert!(reg.list(Some("j")).is_empty());
2540 assert!(matches!(
2542 rx1.try_recv(),
2543 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
2544 ));
2545 assert_eq!(reg.list(Some("someone")).len(), 1);
2546 assert!(matches!(
2547 rx2.try_recv(),
2548 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
2549 ));
2550 }
2551
2552 #[test]
2553 fn a_burst_of_changes_wakes_each_device_once_when_it_goes_quiet() {
2554 let t0 = std::time::Instant::now();
2555 let s = std::time::Duration::from_secs;
2556 let phone = || ("dev-1".to_string(), "j".to_string());
2557 let ipad = || ("dev-2".to_string(), "j".to_string());
2558 let mut wakes = Wakes {
2559 pending: Vec::new(),
2560 timer: false,
2561 };
2562
2563 wakes.add([phone()], t0);
2564 wakes.add([phone(), ipad()], t0 + s(10));
2565 wakes.add([phone()], t0 + s(20));
2566 assert_eq!(wakes.pending.len(), 2);
2567
2568 assert!(wakes.take_due(t0 + s(39)).is_empty());
2570 assert_eq!(wakes.take_due(t0 + s(40)), [ipad()]);
2571 assert_eq!(wakes.next(), Some(t0 + s(50)));
2572 assert_eq!(wakes.take_due(t0 + s(50)), [phone()]);
2573 assert_eq!(wakes.next(), None);
2574 }
2575
2576 #[test]
2577 fn a_library_that_never_goes_quiet_still_wakes_devices() {
2578 let t0 = std::time::Instant::now();
2579 let phone = || ("dev-1".to_string(), "j".to_string());
2580 let mut wakes = Wakes {
2581 pending: Vec::new(),
2582 timer: false,
2583 };
2584 let mut sent = 0;
2585 for i in 0..40 {
2586 let now = t0 + std::time::Duration::from_secs(i * 10);
2587 sent += wakes.take_due(now).len();
2588 wakes.add([phone()], now);
2589 }
2590 assert_eq!(sent, 1);
2593 assert_eq!(wakes.pending.len(), 1);
2594 }
2595
2596 #[test]
2597 fn the_device_playing_is_the_one_meant() {
2598 let reg = Registry::default();
2599 let (tx1, _rx1) = tokio::sync::mpsc::unbounded_channel();
2600 let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel();
2601 let mac = reg.register("j", "mac", "macos", "dev-1", tx1, false, false);
2602 let phone = reg.register("j", "phone", "ios", "dev-2", tx2, false, false);
2603
2604 let err = reg.send(Some("j"), None, LinkCommand::Pause).unwrap_err();
2606 assert!(err.contains("mac") && err.contains("phone"), "{err}");
2607
2608 reg.report(
2609 &phone,
2610 LinkState {
2611 playing: true,
2612 ..Default::default()
2613 },
2614 );
2615 assert_eq!(
2616 reg.send(Some("j"), None, LinkCommand::Pause).unwrap().id,
2617 phone
2618 );
2619 assert_eq!(rx2.try_recv().unwrap().command, LinkCommand::Pause);
2620
2621 reg.report(&phone, LinkState::default());
2623 assert_eq!(
2624 reg.send(Some("j"), None, LinkCommand::Pause).unwrap().id,
2625 phone
2626 );
2627 let _ = mac;
2628 }
2629
2630 fn drain(rx: &mut tokio::sync::mpsc::UnboundedReceiver<Envelope>) -> Vec<LinkCommand> {
2631 std::iter::from_fn(|| rx.try_recv().ok().map(|e| e.command)).collect()
2632 }
2633
2634 fn shared() -> (
2636 Registry,
2637 tokio::sync::mpsc::UnboundedReceiver<Envelope>,
2638 tokio::sync::mpsc::UnboundedReceiver<Envelope>,
2639 tokio::sync::mpsc::UnboundedReceiver<Envelope>,
2640 ) {
2641 let reg = Registry::default();
2642 let (phone_tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2643 let (mac_tx, mac) = tokio::sync::mpsc::unbounded_channel();
2644 let (k_tx, k) = tokio::sync::mpsc::unbounded_channel();
2645 let id = reg.register("j", "phone", "ios", "dev-phone", phone_tx, true, false);
2646 reg.register("j", "mac", "macos", "dev-mac", mac_tx, true, false);
2647 reg.register("k", "laptop", "macos", "dev-k", k_tx, true, false);
2648 reg.report(
2649 &id,
2650 LinkState {
2651 playing: true,
2652 title: Some("Roygbiv".into()),
2653 outputs: Some(Default::default()),
2654 ..Default::default()
2655 },
2656 );
2657 reg.share("j", "dev-phone", "k", true).unwrap();
2658 drain(&mut phone);
2659 (reg, phone, mac, k)
2660 }
2661
2662 #[test]
2663 fn a_grantee_sees_the_shared_device_and_nothing_else_of_the_owner() {
2664 let (_reg, _phone, _mac, mut k) = shared();
2665 let listed = drain(&mut k)
2666 .into_iter()
2667 .rev()
2668 .find_map(|c| match c {
2669 LinkCommand::Devices { devices } => Some(devices),
2670 _ => None,
2671 })
2672 .expect("told of it");
2673 assert_eq!(listed.len(), 1, "the phone, not the Mac");
2674 let phone = &listed[0];
2675 assert_eq!(phone.id, "dev-phone");
2676 assert_eq!(phone.owner.as_deref(), Some("j"));
2677 let state = phone.state.as_ref().expect("its state");
2678 assert_eq!(state.title.as_deref(), Some("Roygbiv"));
2679 assert!(
2680 state.outputs.is_some(),
2681 "outputs, to choose from: the output is in the playback set"
2682 );
2683 }
2684
2685 #[test]
2691 fn a_grantee_controls_playback_as_itself_and_nothing_of_the_owners_account() {
2692 let (reg, mut phone, mut mac, _k) = shared();
2693 let wrapped = |c: LinkCommand| LinkCommand::Shared {
2694 command: Box::new(c),
2695 };
2696 for cmd in [
2697 LinkCommand::Pause,
2698 LinkCommand::SetRendererVolume { volume: 40 },
2699 LinkCommand::SleepTimer {
2700 timer: Some(koan_core::player::state::SleepTimer::After { minutes: 30 }),
2701 },
2702 LinkCommand::HandOff { to: "dev-k".into() },
2703 ] {
2704 reg.relay("k", "dev-phone", cmd.clone()).unwrap();
2705 assert_eq!(drain(&mut phone), [wrapped(cmd)]);
2706 }
2707 for cmd in [
2708 LinkCommand::Sync { full: false },
2709 LinkCommand::Evict { track_ids: vec![] },
2710 LinkCommand::Shared {
2711 command: Box::new(LinkCommand::Pause),
2712 },
2713 ] {
2714 assert!(!cmd.allowed_playback());
2715 assert!(reg.relay("k", "dev-phone", cmd).is_err());
2716 }
2717 assert!(drain(&mut phone).is_empty());
2718 assert!(
2719 reg.relay("k", "dev-mac", LinkCommand::Pause).is_err(),
2720 "not shared, not reachable"
2721 );
2722 assert!(
2723 drain(&mut mac)
2724 .iter()
2725 .all(|c| matches!(c, LinkCommand::Devices { .. } | LinkCommand::Shares { .. })),
2726 "news, and no command"
2727 );
2728 assert!(reg.shared_owner("k", "dev-mac").is_none(), "nor wakeable");
2729 }
2730
2731 #[test]
2735 fn a_hand_off_runs_both_ways_across_a_grant_and_nothing_else_does() {
2736 let (reg, _phone, _mac, mut k) = shared();
2737 drain(&mut k);
2738 let play = LinkCommand::Play {
2739 track_ids: vec!["t".into()],
2740 start_at: 0,
2741 position_ms: 1000,
2742 paused: false,
2743 handoff: true,
2744 mode: koan_core::player::state::QueueMode::Keep,
2745 };
2746 reg.relay_from("j", Some("dev-phone"), "dev-k", play.clone())
2747 .unwrap();
2748 assert!(drain(&mut k).contains(&LinkCommand::Shared {
2749 command: Box::new(play.clone())
2750 }));
2751 assert!(
2752 reg.relay_from("j", Some("dev-phone"), "dev-k", LinkCommand::Pause)
2753 .is_err(),
2754 "the owner's phone does not command the grantee's devices"
2755 );
2756 assert!(
2757 reg.relay_from("j", Some("dev-mac"), "dev-k", play.clone())
2758 .is_err(),
2759 "only the shared device"
2760 );
2761 let not_a_hand_off = LinkCommand::Play {
2762 track_ids: vec!["t".into()],
2763 start_at: 0,
2764 position_ms: 0,
2765 paused: false,
2766 handoff: false,
2767 mode: koan_core::player::state::QueueMode::Keep,
2768 };
2769 assert!(
2770 reg.relay_from("j", Some("dev-phone"), "dev-k", not_a_hand_off)
2771 .is_err(),
2772 "a hand-off, not any play"
2773 );
2774 }
2775
2776 #[test]
2777 fn revoking_ends_control_at_once() {
2778 let (reg, mut phone, _mac, mut k) = shared();
2779 reg.share("j", "dev-phone", "k", false).unwrap();
2780 assert!(reg.relay("k", "dev-phone", LinkCommand::Pause).is_err());
2781 assert!(
2782 drain(&mut phone)
2783 .iter()
2784 .all(|c| !matches!(c, LinkCommand::Pause))
2785 );
2786 assert!(reg.shared_owner("k", "dev-phone").is_none());
2787 let listed = drain(&mut k)
2788 .into_iter()
2789 .rev()
2790 .find_map(|c| match c {
2791 LinkCommand::Devices { devices } => Some(devices),
2792 _ => None,
2793 })
2794 .expect("told it is gone");
2795 assert!(listed.is_empty());
2796 }
2797
2798 #[test]
2799 fn a_device_hears_whom_it_is_shared_with_every_time_it_links() {
2800 let reg = Registry::default();
2801 let (tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2802 reg.register("j", "phone", "ios", "dev-phone", tx, true, false);
2803 let first = drain(&mut phone);
2804 assert!(
2805 first.contains(&LinkCommand::Shares {
2806 grantees: vec![],
2807 error: None,
2808 accounts: vec![],
2809 }),
2810 "an empty list replaces one from another server"
2811 );
2812 reg.send_shares(
2813 "j",
2814 "dev-phone",
2815 Some("There is no account called x".into()),
2816 );
2817 assert!(drain(&mut phone).iter().any(
2818 |c| matches!(c, LinkCommand::Shares { error: Some(e), .. } if e.contains("no account"))
2819 ));
2820 }
2821
2822 #[test]
2823 fn the_owner_is_told_who_it_shares_with() {
2824 let reg = Registry::default();
2825 let (tx, mut phone) = tokio::sync::mpsc::unbounded_channel();
2826 reg.register("j", "phone", "ios", "dev-phone", tx, true, false);
2827 reg.share("j", "dev-phone", "k", true).unwrap();
2828 reg.share("j", "dev-phone", "m", true).unwrap();
2829 let last = drain(&mut phone).into_iter().rev().find_map(|c| match c {
2830 LinkCommand::Shares { grantees, .. } => Some(grantees),
2831 _ => None,
2832 });
2833 assert_eq!(last, Some(vec!["k".to_string(), "m".to_string()]));
2834 assert!(
2835 reg.share("j", "dev-phone", "j", true).is_err(),
2836 "not with itself"
2837 );
2838 }
2839
2840 #[test]
2844 fn a_device_is_woken_for_another_account_on_its_network_or_by_grant() {
2845 let reg = Registry::default();
2846 let home: std::net::IpAddr = "203.0.113.7".parse().unwrap();
2847 let away: std::net::IpAddr = "198.51.100.2".parse().unwrap();
2848 reg.seen_at("dev-ipad", "sarita", home);
2849 reg.seen_at("dev-phone", "admin", home);
2850 assert_eq!(
2851 reg.wake_owner("admin", "dev-phone", "dev-ipad"),
2852 "sarita",
2853 "same address: the iPad's own token"
2854 );
2855 reg.seen_at("dev-phone", "admin", away);
2856 assert_eq!(
2857 reg.wake_owner("admin", "dev-phone", "dev-ipad"),
2858 "admin",
2859 "elsewhere, with no grant: only admin's own, which it is not"
2860 );
2861 reg.share("sarita", "dev-ipad", "admin", true).unwrap();
2862 assert_eq!(
2863 reg.wake_owner("admin", "dev-phone", "dev-ipad"),
2864 "sarita",
2865 "shared: from anywhere"
2866 );
2867 reg.seen_at("dev-other", "mallory", home);
2869 assert_eq!(reg.wake_owner("admin", "dev-other", "dev-tv"), "admin");
2870 }
2871
2872 #[test]
2875 fn where_a_device_last_linked_from_survives_a_restart() {
2876 let conn = rusqlite::Connection::open_in_memory().unwrap();
2877 koan_core::db::schema::create_tables(&conn).unwrap();
2878 for (device, user, seen) in [("dev-ipad", "sarita", 1), ("dev-phone", "admin", 2)] {
2879 conn.execute(
2880 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES (?1, ?2, ?1, 'ios', ?3)",
2881 rusqlite::params![device, user, seen],
2882 )
2883 .unwrap();
2884 }
2885 let home: std::net::IpAddr = "203.0.113.7".parse().unwrap();
2886 outbox::save_address_in(&conn, "dev-ipad", "sarita", home);
2887 outbox::save_address_in(&conn, "dev-phone", "admin", home);
2888
2889 let reg = Registry::default();
2891 *reg.addresses.lock() = outbox::load_addresses_in(&conn);
2892 assert_eq!(
2893 reg.wake_owner("admin", "dev-phone", "dev-ipad"),
2894 "sarita",
2895 "still woken through its own account after the restart"
2896 );
2897 }
2898
2899 #[test]
2903 fn a_forgotten_device_is_dropped_by_the_accounts_devices() {
2904 let reg = Registry::default();
2905 let (mac_tx, mut mac) = tokio::sync::mpsc::unbounded_channel();
2906 let (other_tx, mut other) = tokio::sync::mpsc::unbounded_channel();
2907 reg.register("j", "mac", "macos", "dev-mac", mac_tx, true, false);
2908 reg.register("k", "laptop", "macos", "dev-k", other_tx, true, false);
2909 while mac.try_recv().is_ok() {}
2910 while other.try_recv().is_ok() {}
2911
2912 reg.forget("j", "dev-phone").unwrap();
2913 let heard: Vec<LinkCommand> =
2914 std::iter::from_fn(|| mac.try_recv().ok().map(|e| e.command)).collect();
2915 assert!(heard.contains(&LinkCommand::Forgotten {
2916 device: "dev-phone".into()
2917 }));
2918 assert!(
2919 std::iter::from_fn(|| other.try_recv().ok().map(|e| e.command))
2920 .all(|c| !matches!(c, LinkCommand::Forgotten { .. })),
2921 "another account hears nothing of it"
2922 );
2923 assert!(
2924 reg.forget("j", "dev-mac").is_err(),
2925 "linked: it would be back"
2926 );
2927 assert!(
2928 reg.relay(
2929 "j",
2930 "dev-mac",
2931 LinkCommand::Forgotten { device: "x".into() }
2932 )
2933 .is_err(),
2934 "news from the server, not a command a device may send"
2935 );
2936 }
2937
2938 #[test]
2941 fn forgetting_a_shared_device_declines_the_share() {
2942 let (reg, mut phone, _mac, mut k) = shared();
2943 drain(&mut k);
2944 reg.forget("k", "dev-phone").unwrap();
2945 assert!(reg.shared_owner("k", "dev-phone").is_none());
2946 assert!(
2947 drain(&mut phone)
2948 .iter()
2949 .any(|c| matches!(c, LinkCommand::Shares { grantees, .. } if grantees.is_empty()))
2950 );
2951 let listed = drain(&mut k).into_iter().rev().find_map(|c| match c {
2952 LinkCommand::Devices { devices } => Some(devices),
2953 _ => None,
2954 });
2955 assert_eq!(listed.map(|d| d.len()), Some(0));
2956 }
2957
2958 #[test]
2961 fn forgetting_a_device_stops_pushes_to_it_and_only_for_its_account() {
2962 let conn = rusqlite::Connection::open_in_memory().unwrap();
2963 koan_core::db::schema::create_tables(&conn).unwrap();
2964 for user in ["j", "k"] {
2965 conn.execute(
2966 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES ('dev-phone', ?1, 'phone', 'ios', 1)",
2967 [user],
2968 )
2969 .unwrap();
2970 conn.execute(
2971 "INSERT INTO link_push (device, username, token, sandbox, updated_at) VALUES ('dev-phone', ?1, 't', 0, 1)",
2972 [user],
2973 )
2974 .unwrap();
2975 }
2976 outbox::forget_device_in(&conn, "dev-phone", "k");
2977 assert_eq!(
2978 outbox::push_targets_in(&conn, Some("j")).len(),
2979 1,
2980 "k forgetting its own leaves j's alone"
2981 );
2982 outbox::forget_device_in(&conn, "dev-phone", "j");
2983 assert!(outbox::push_targets_in(&conn, Some("j")).is_empty());
2984 assert!(outbox::push_targets_in(&conn, None).is_empty());
2985 }
2986
2987 #[test]
2988 fn a_device_hears_its_peers_and_commands_reach_them_by_device_id() {
2989 let reg = Registry::default();
2990 let (tx1, mut rx1) = tokio::sync::mpsc::unbounded_channel();
2991 let (tx2, mut rx2) = tokio::sync::mpsc::unbounded_channel();
2992 let (tx3, mut rx3) = tokio::sync::mpsc::unbounded_channel();
2993 reg.register("j", "mac", "macos", "dev-mac", tx1, true, false);
2994 let phone = reg.register("j", "phone", "ios", "dev-phone", tx2, true, false);
2995 reg.register("someone", "laptop", "macos", "dev-other", tx3, true, false);
2996
2997 reg.report(
2998 &phone,
2999 LinkState {
3000 playing: true,
3001 title: Some("Roygbiv".into()),
3002 ..Default::default()
3003 },
3004 );
3005 let devices = drain(&mut rx1)
3006 .into_iter()
3007 .rev()
3008 .find_map(|c| match c {
3009 LinkCommand::Devices { devices } => Some(devices),
3010 _ => None,
3011 })
3012 .expect("the Mac is told of the phone");
3013 assert_eq!(devices.len(), 1, "not itself, not another account's");
3014 assert_eq!(devices[0].id, "dev-phone");
3015 assert_eq!(
3016 devices[0].state.as_ref().and_then(|s| s.title.as_deref()),
3017 Some("Roygbiv")
3018 );
3019 while rx3.try_recv().is_ok() {}
3020 assert!(rx3.try_recv().is_err());
3021
3022 while rx2.try_recv().is_ok() {}
3023 reg.relay("j", "dev-phone", LinkCommand::Pause).unwrap();
3024 assert_eq!(rx2.try_recv().unwrap().command, LinkCommand::Pause);
3025 assert!(
3026 reg.relay("someone", "dev-phone", LinkCommand::Pause)
3027 .is_err(),
3028 "another account cannot reach it"
3029 );
3030 assert!(
3031 reg.relay("j", "dev-phone", LinkCommand::Devices { devices: vec![] })
3032 .is_err()
3033 );
3034 }
3035
3036 #[test]
3037 fn a_device_linked_for_a_month_is_not_forgotten() {
3038 let conn = rusqlite::Connection::open_in_memory().unwrap();
3039 koan_core::db::schema::create_tables(&conn).unwrap();
3040 let now = 100 * 24 * 60 * 60;
3041 let long_ago = now - 40 * 24 * 60 * 60;
3042 for device in ["mac", "old-phone"] {
3043 conn.execute(
3044 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES (?1, 'j', ?1, 'ios', ?2)",
3045 rusqlite::params![device, long_ago],
3046 )
3047 .unwrap();
3048 conn.execute(
3049 "INSERT INTO link_push (device, username, token, sandbox, updated_at) VALUES (?1, 'j', 't', 0, ?2)",
3050 rusqlite::params![device, long_ago],
3051 )
3052 .unwrap();
3053 }
3054 outbox::forget_stale(&conn, &[("mac".into(), "j".into())], now);
3055 let left = |table: &str| -> Vec<String> {
3056 conn.prepare(&format!("SELECT device FROM {table} ORDER BY device"))
3057 .unwrap()
3058 .query_map([], |r| r.get(0))
3059 .unwrap()
3060 .collect::<Result<_, _>>()
3061 .unwrap()
3062 };
3063 assert_eq!(left("link_devices"), ["mac"]);
3064 assert_eq!(left("link_push"), ["mac"]);
3065 }
3066
3067 #[test]
3068 fn a_paired_apple_tv_is_kept_however_long_it_sleeps() {
3069 let conn = rusqlite::Connection::open_in_memory().unwrap();
3070 koan_core::db::schema::create_tables(&conn).unwrap();
3071 let now = 100 * 24 * 60 * 60;
3072 let long_ago = now - 90 * 24 * 60 * 60;
3073 for (device, platform) in [("tv", "tvos"), ("other-tv", "tvos")] {
3074 conn.execute(
3075 "INSERT INTO link_devices (device, username, name, platform, last_seen) VALUES (?1, 'j', 'Living Room', ?2, ?3)",
3076 rusqlite::params![device, platform, long_ago],
3077 )
3078 .unwrap();
3079 }
3080 let pairing = koan_core::remote::companion::Credential {
3081 tv_id: "7A821767-01ED-442C-B4BB-6BDEA29109DA".into(),
3082 tv_key: "a2V5".into(),
3083 id: "ME".into(),
3084 seed: "c2VlZA==".into(),
3085 };
3086 conn.execute(
3087 "INSERT INTO link_tv (device, username, pairing, updated_at) VALUES ('tv', 'j', ?1, 0)",
3088 [serde_json::to_string(&pairing).unwrap()],
3089 )
3090 .unwrap();
3091 outbox::forget_stale(&conn, &[], now);
3092 let paired = outbox::tv_pairings_in(&conn, "j");
3093 assert_eq!(paired.len(), 1);
3094 assert_eq!(paired[0].device, "tv");
3095 assert_eq!(paired[0].name, "Living Room");
3096 assert_eq!(paired[0].pairing, pairing);
3097 assert!(outbox::tv_pairings_in(&conn, "someone").is_empty());
3098 let devices: i64 = conn
3099 .query_row("SELECT COUNT(*) FROM link_devices", [], |r| r.get(0))
3100 .unwrap();
3101 assert_eq!(devices, 1, "the unpaired TV is forgotten as before");
3102 }
3103}