Skip to main content

koan_core/remote/
link.rs

1//! A koan client's standing connection to the koan server it syncs from.
2//!
3//! The server can then act on the client: play a list of tracks on a phone, or
4//! pause it. Subsonic has no way for a server to reach a client, so this is a
5//! koan extension: a WebSocket at `/rest/koanLink`, authenticated like every
6//! other `/rest` call, carrying [`LinkCommand`]s as JSON text frames from the
7//! server. Track ids are the server's, which a synced client holds as each
8//! track's `remote_id`.
9
10use std::collections::HashMap;
11use std::net::TcpStream;
12use std::os::fd::RawFd;
13use std::path::Path;
14use std::sync::{Arc, LazyLock};
15use std::time::{Duration, Instant};
16
17use parking_lot::{Condvar, Mutex};
18use serde::{Deserialize, Serialize};
19use tungstenite::stream::MaybeTlsStream;
20
21use crate::config::{self, Config};
22use crate::helpers::{subsonic_auth, subsonic_client};
23use crate::remote::client::SubsonicAuth;
24use crate::remote::profile;
25use crate::remote::wire::{self, Waker};
26
27/// What a server asks a linked client to do.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "camelCase")]
30pub enum LinkCommand {
31    /// Replace the queue with these tracks and play from `start_at`,
32    /// `position_ms` into it.
33    #[serde(rename_all = "camelCase")]
34    Play {
35        track_ids: Vec<String>,
36        #[serde(default)]
37        start_at: u32,
38        #[serde(default, skip_serializing_if = "is_zero")]
39        position_ms: u64,
40    },
41    /// Append these tracks to the queue.
42    #[serde(rename_all = "camelCase")]
43    Enqueue {
44        track_ids: Vec<String>,
45    },
46    /// Insert these tracks after the current one.
47    #[serde(rename_all = "camelCase")]
48    PlayNext {
49        track_ids: Vec<String>,
50    },
51    /// Take every queue entry for these tracks out of the queue.
52    #[serde(rename_all = "camelCase")]
53    Remove {
54        track_ids: Vec<String>,
55    },
56    Clear,
57    Radio {
58        enabled: bool,
59    },
60    /// Pull what the server has changed: library, favourites and playlists.
61    /// `full` walks every track rather than what changed.
62    Sync {
63        #[serde(default)]
64        full: bool,
65    },
66    /// Delete the downloaded copies of these tracks, so the next play fetches
67    /// them again: for a copy that was cached while the server's was bad.
68    #[serde(rename_all = "camelCase")]
69    Evict {
70        track_ids: Vec<String>,
71    },
72    /// Play this track: from where it sits in the queue, or slotted in after
73    /// the current one when the queue does not hold it.
74    #[serde(rename_all = "camelCase")]
75    JumpTo {
76        track_id: String,
77    },
78    #[serde(rename_all = "camelCase")]
79    Seek {
80        position_ms: u64,
81    },
82    Pause,
83    Resume,
84    Next,
85    Previous,
86    /// Play this queue entry, by the id the device reported it under. Unlike
87    /// `JumpTo` it names one entry of a track queued twice, and reaches a file
88    /// only that device has.
89    PlayItem {
90        id: String,
91    },
92    RemoveItems {
93        ids: Vec<String>,
94    },
95    /// Move these queue entries before or after `target`, in the order given.
96    MoveItems {
97        ids: Vec<String>,
98        target: String,
99        after: bool,
100    },
101    /// Insert these tracks after the queue entry `after`.
102    #[serde(rename_all = "camelCase")]
103    Insert {
104        track_ids: Vec<String>,
105        after: String,
106    },
107    Undo,
108    Redo,
109    /// Send this device's queue and playhead to the device `to`, as a `play`,
110    /// and pause here. The device holding the queue does it, so taking music
111    /// from another device and sending it there are one command.
112    HandOff {
113        to: String,
114    },
115    /// The account's other devices, as they are now. News rather than a
116    /// command: sent whenever one of them changes, to links that asked for it.
117    Devices {
118        devices: Vec<LinkDevice>,
119    },
120}
121
122fn is_zero(n: &u64) -> bool {
123    *n == 0
124}
125
126impl LinkCommand {
127    /// Whether a device on the same network, which may belong to anyone, may
128    /// send this. Playback and the queue; nothing that touches the library or
129    /// the files on disk.
130    pub fn allowed_nearby(&self) -> bool {
131        !matches!(
132            self,
133            Self::Sync { .. } | Self::Evict { .. } | Self::Devices { .. }
134        )
135    }
136
137    /// The server's ids for the tracks this command names, if any.
138    pub fn track_ids(&self) -> &[String] {
139        match self {
140            Self::Play { track_ids, .. }
141            | Self::Enqueue { track_ids }
142            | Self::PlayNext { track_ids }
143            | Self::Remove { track_ids }
144            | Self::Evict { track_ids }
145            | Self::Insert { track_ids, .. } => track_ids,
146            Self::JumpTo { track_id } => std::slice::from_ref(track_id),
147            _ => &[],
148        }
149    }
150}
151
152/// Where a command came from, which decides what it may cost.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum CommandSource {
155    /// The signed-in server, or this person's own devices through it.
156    Account,
157    /// A device on the same network, which may belong to anyone.
158    Nearby,
159}
160
161/// Another device on the same account, as the server sends it.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct LinkDevice {
165    /// The device's own id: stable across its reconnects.
166    pub id: String,
167    pub name: String,
168    pub platform: String,
169    /// Linked now. A device that is not is one iOS has suspended: a command
170    /// wakes it, and music reaches it as a notification to tap.
171    pub linked: bool,
172    /// What it last reported, with the playhead placed as of sending. `None`
173    /// until it has reported at all.
174    pub state: Option<LinkState>,
175}
176
177impl LinkCommand {
178    /// Every track id the command carries, for translating between the
179    /// server's row ids and the uids it publishes.
180    pub fn track_ids_mut(&mut self) -> Vec<&mut String> {
181        match self {
182            Self::Play { track_ids, .. }
183            | Self::Enqueue { track_ids }
184            | Self::PlayNext { track_ids }
185            | Self::Remove { track_ids }
186            | Self::Evict { track_ids }
187            | Self::Insert { track_ids, .. } => track_ids.iter_mut().collect(),
188            Self::JumpTo { track_id } => vec![track_id],
189            Self::PlayItem { .. }
190            | Self::RemoveItems { .. }
191            | Self::MoveItems { .. }
192            | Self::Undo
193            | Self::Redo
194            | Self::HandOff { .. }
195            | Self::Devices { .. }
196            | Self::Clear
197            | Self::Radio { .. }
198            | Self::Sync { .. }
199            | Self::Seek { .. }
200            | Self::Pause
201            | Self::Resume
202            | Self::Next
203            | Self::Previous => Vec::new(),
204        }
205    }
206}
207
208/// What a linked client tells the server about itself, as it changes.
209#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct LinkState {
212    pub playing: bool,
213    pub title: Option<String>,
214    pub artist: Option<String>,
215    #[serde(default)]
216    pub album: Option<String>,
217    /// Into the current track, as of when this was sent.
218    #[serde(default)]
219    pub position_ms: u64,
220    #[serde(default)]
221    pub duration_ms: u64,
222    #[serde(default)]
223    pub radio: bool,
224    /// The queue, or the part of it around the current track when it is long.
225    #[serde(default)]
226    pub queue: Vec<LinkQueueEntry>,
227}
228
229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct LinkQueueEntry {
232    /// The queue entry's own id on that device, for `playItem` and the rest.
233    #[serde(default)]
234    pub id: Option<String>,
235    /// The server's id for the track; `None` for a file only this device has.
236    pub track_id: Option<String>,
237    pub title: String,
238    pub artist: String,
239    #[serde(default)]
240    pub album: String,
241    #[serde(default)]
242    pub duration_ms: u64,
243    pub current: bool,
244}
245
246impl LinkState {
247    /// Whether `self` says something `sent`, reported `elapsed` ago, did not.
248    /// A playhead moving at one second per second is not news; a seek, a
249    /// pause, another track or an edited queue is.
250    pub fn differs(&self, sent: &LinkState, elapsed: Duration) -> bool {
251        let strip = |s: &LinkState| LinkState {
252            position_ms: 0,
253            ..s.clone()
254        };
255        if strip(self) != strip(sent) {
256            return true;
257        }
258        let expected = if sent.playing {
259            sent.position_ms + elapsed.as_millis() as u64
260        } else {
261            sent.position_ms
262        };
263        self.position_ms.abs_diff(expected) > 3000
264    }
265}
266
267/// A message from a client, up the same socket.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(tag = "type", rename_all = "camelCase")]
270pub enum LinkReport {
271    State(LinkState),
272    /// Where Apple's push service reaches this device, so the server can wake
273    /// it once iOS has suspended it and the socket is gone. `sandbox` for a
274    /// development build, whose tokens only the sandbox gateway accepts.
275    Push {
276        token: String,
277        sandbox: bool,
278    },
279    /// Send `command` to the device `to` on the same account.
280    Command {
281        to: String,
282        command: LinkCommand,
283    },
284    /// Where to push updates to a Live Activity showing the device `device`;
285    /// `None` for both when the activity has ended.
286    Activity {
287        token: Option<String>,
288        device: Option<String>,
289        #[serde(default)]
290        sandbox: bool,
291    },
292    /// Who is at the other end of a connection made on the local network.
293    Hello(LinkHello),
294}
295
296/// How a device introduces itself to one that connected to it over the local
297/// network, before anything else.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct LinkHello {
301    pub id: String,
302    pub name: String,
303    pub platform: String,
304    /// Which library it plays from, as `library_fingerprint` gives it; `None`
305    /// when it is signed in to none. Two devices with the same one share track
306    /// ids, so music can be handed between them.
307    pub library: Option<String>,
308}
309
310/// The server this client plays from, as something two devices can compare
311/// without either saying its address to the network.
312pub fn library_fingerprint(cfg: &Config) -> Option<String> {
313    let auth = subsonic_auth(cfg)?;
314    let url = auth.base_url.trim_end_matches('/').to_ascii_lowercase();
315    Some(format!("{:x}", md5::compute(url.as_bytes())))
316}
317
318/// This device's push token, once the OS has issued one. Set by the app; sent
319/// up each link as it opens, and again if it changes.
320static PUSH_TOKEN: Mutex<Option<(String, bool)>> = Mutex::new(None);
321
322/// A Live Activity on this device showing another: its push token, the device
323/// it shows, and whether the token is the sandbox's. Sent up each link as it
324/// opens, and again when it changes; `None` once the activity has ended.
325type ActivityToken = (String, String, bool);
326static ACTIVITY: Mutex<Option<Option<ActivityToken>>> = Mutex::new(None);
327
328/// Record where the server should push updates to this device's Live
329/// Activity, or that there is none now.
330pub fn set_activity(activity: Option<ActivityToken>) {
331    *ACTIVITY.lock() = Some(activity);
332    if let Some(up) = LINK.lock().as_ref() {
333        up.waker.wake();
334    }
335}
336
337/// A command as a push notification carries it: the same JSON as over the
338/// link.
339pub fn parse_command(json: &str) -> Result<LinkCommand, String> {
340    serde_json::from_str(json).map_err(|e| e.to_string())
341}
342
343/// Record the push token the OS issued this app, and link now to send it.
344pub fn set_push_token(token: String, sandbox: bool) {
345    *PUSH_TOKEN.lock() = Some((token, sandbox));
346    nudge();
347    if let Some(up) = LINK.lock().as_ref() {
348        up.waker.wake();
349    }
350}
351
352/// How a client describes itself when it links.
353#[derive(Debug, Clone)]
354pub struct LinkIdentity {
355    /// Shown to whoever picks a client to play on: "James's iPhone".
356    pub name: String,
357    /// `ios`, `macos` or `linux`.
358    pub platform: String,
359    /// Stable across restarts, so a reconnect replaces its own entry on the
360    /// server rather than listing the device twice.
361    pub device_id: String,
362}
363
364impl LinkIdentity {
365    /// This machine, named `name` or else by its hostname.
366    pub fn this_device(name: Option<String>) -> Self {
367        let (platform, label) = if cfg!(target_os = "ios") {
368            ("ios", "iPhone")
369        } else if cfg!(target_os = "macos") {
370            ("macos", "Mac")
371        } else {
372            ("linux", "Linux")
373        };
374        Self {
375            name: name
376                .filter(|n| !n.trim().is_empty())
377                .or_else(hostname)
378                .unwrap_or_else(|| label.to_string()),
379            platform: platform.to_string(),
380            device_id: device_id(&config::config_dir()),
381        }
382    }
383}
384
385/// What this device offers whatever controls it: who it is, what it is doing,
386/// and what to do with a command. The link to the server and the connections
387/// made on the local network all serve the same one.
388#[derive(Clone)]
389pub struct Local {
390    pub identity: LinkIdentity,
391    pub state: Arc<dyn Fn() -> LinkState + Send + Sync>,
392    pub on_command: Arc<dyn Fn(LinkCommand, CommandSource) + Send + Sync>,
393}
394
395/// Keep a link open to the configured server for as long as the process runs,
396/// handing each command to `on_command` on the link's own thread, and telling
397/// the server what `state` says whenever it changes: which of a person's
398/// devices is the one playing is how the server picks where to send music.
399///
400/// Reads the config before every attempt, so signing in later links without a
401/// restart. Links only to a server whose profile says it can: Navidrome has no
402/// such endpoint.
403pub fn spawn(local: Local) {
404    std::thread::Builder::new()
405        .name("koan-link".into())
406        .spawn(move || run(local))
407        .expect("failed to spawn the link thread");
408}
409
410const RETRY_MIN: Duration = Duration::from_secs(2);
411const RETRY_MAX: Duration = Duration::from_secs(60);
412
413fn run(local: Local) {
414    let mut wait = RETRY_MIN;
415    loop {
416        let cfg = Config::load().unwrap_or_default();
417        let Some(auth) = subsonic_auth(&cfg) else {
418            rest(RETRY_MAX);
419            continue;
420        };
421        match profile::for_auth(&auth) {
422            Some(p) if p.links() => {}
423            // Not a server that links; asked again when the sign-in changes.
424            Some(_) => {
425                rest(RETRY_MAX);
426                continue;
427            }
428            None => {
429                rest(wait);
430                wait = (wait * 2).min(RETRY_MAX);
431                continue;
432            }
433        }
434
435        match connect(&auth, &local.identity) {
436            Ok((mut socket, fd)) => {
437                log::info!("link: connected to {}", auth.base_url);
438                wait = RETRY_MIN;
439                if let Err(e) = serve(&mut socket, fd, &local) {
440                    log::info!("link: closed: {e}");
441                }
442                *LINK.lock() = None;
443                crate::remote::devices::set_linked(false);
444            }
445            Err(e) => {
446                log::warn!("link: {e}");
447                // Asked again before the next attempt: the server may have
448                // been replaced by one that does not link. Not after a link
449                // that simply dropped, which is every time iOS suspends the
450                // app: re-asking then would put two round trips in front of
451                // every reconnect.
452                profile::forget();
453            }
454        }
455        if rest(wait) {
456            wait = RETRY_MIN;
457            continue;
458        }
459        wait = (wait * 2).min(RETRY_MAX);
460    }
461}
462
463static NUDGE: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
464
465/// Try to link again now, rather than when the backoff runs out.
466///
467/// For an app coming back to the foreground: iOS suspends a backgrounded app,
468/// its link dies with it, and the retry it was sleeping towards can be a minute
469/// away. Does nothing to a link that is up.
470pub fn nudge() {
471    *NUDGE.0.lock() = true;
472    NUDGE.1.notify_all();
473}
474
475/// Wait `d`, or less if nudged. True when nudged.
476fn rest(d: Duration) -> bool {
477    let mut nudged = NUDGE.0.lock();
478    if !*nudged {
479        NUDGE.1.wait_for(&mut nudged, d);
480    }
481    std::mem::replace(&mut *nudged, false)
482}
483
484/// The link while it is up: what waits to go up it, and how to wake it.
485struct Up {
486    waker: Arc<Waker>,
487    outbox: Vec<LinkReport>,
488}
489
490static LINK: Mutex<Option<Up>> = Mutex::new(None);
491
492/// Send `report` up the link. False when the link is down.
493pub fn report(report: LinkReport) -> bool {
494    let mut link = LINK.lock();
495    let Some(up) = link.as_mut() else {
496        return false;
497    };
498    up.outbox.push(report);
499    up.waker.wake();
500    true
501}
502
503type Socket = tungstenite::WebSocket<MaybeTlsStream<TcpStream>>;
504
505fn connect(auth: &SubsonicAuth, identity: &LinkIdentity) -> Result<(Socket, RawFd), String> {
506    let url = link_url(auth, identity)?;
507    let (socket, _) = tungstenite::connect(url).map_err(|e| e.to_string())?;
508    let fd = wire::prepare(socket.get_ref())?;
509    Ok((socket, fd))
510}
511
512/// `/rest/koanLink` with the same credentials every other call carries.
513fn link_url(auth: &SubsonicAuth, identity: &LinkIdentity) -> Result<String, String> {
514    let base = if let Some(rest) = auth.base_url.strip_prefix("https://") {
515        format!("wss://{rest}")
516    } else if let Some(rest) = auth.base_url.strip_prefix("http://") {
517        format!("ws://{rest}")
518    } else {
519        return Err(format!("not an http(s) server: {}", auth.base_url));
520    };
521    let mut query = auth.query().map_err(|e| e.to_string())?;
522    for (k, v) in [
523        ("client", identity.name.as_str()),
524        ("platform", identity.platform.as_str()),
525        ("device", identity.device_id.as_str()),
526        // Send this link the account's other devices. A server that predates
527        // them ignores it.
528        ("devices", "1"),
529    ] {
530        query.push('&');
531        query.push_str(k);
532        query.push('=');
533        query.push_str(&percent_encode(v));
534    }
535    Ok(format!("{base}/rest/koanLink?{query}"))
536}
537
538fn serve(socket: &mut Socket, fd: RawFd, local: &Local) -> Result<(), String> {
539    let waker = Waker::new().map_err(|e| e.to_string())?;
540    wire::wake_on_engine_change(&waker);
541    *LINK.lock() = Some(Up {
542        waker: waker.clone(),
543        outbox: Vec::new(),
544    });
545    crate::remote::devices::set_linked(true);
546    let mut session = LinkSession {
547        local,
548        sent: None,
549        sent_push: None,
550        sent_activity: None,
551    };
552    wire::drive(socket, fd, &waker, &mut session)
553}
554
555struct LinkSession<'a> {
556    local: &'a Local,
557    sent: Option<(LinkState, Instant)>,
558    sent_push: Option<(String, bool)>,
559    sent_activity: Option<Option<ActivityToken>>,
560}
561
562impl wire::Session for LinkSession<'_> {
563    fn outgoing(&mut self) -> Vec<String> {
564        let mut out = Vec::new();
565        let push = PUSH_TOKEN.lock().clone();
566        if let Some((token, sandbox)) = push.clone()
567            && push != self.sent_push
568        {
569            out.push(LinkReport::Push { token, sandbox });
570            self.sent_push = push;
571        }
572        let activity = ACTIVITY.lock().clone();
573        if activity.is_some() && activity != self.sent_activity {
574            let (token, device, sandbox) = match activity.clone().flatten() {
575                Some((t, d, s)) => (Some(t), Some(d), s),
576                None => (None, None, false),
577            };
578            out.push(LinkReport::Activity {
579                token,
580                device,
581                sandbox,
582            });
583            self.sent_activity = activity;
584        }
585        if let Some(up) = LINK.lock().as_mut() {
586            out.append(&mut up.outbox);
587        }
588        let now = (self.local.state)();
589        if self
590            .sent
591            .as_ref()
592            .is_none_or(|(s, at)| now.differs(s, at.elapsed()))
593        {
594            out.push(LinkReport::State(now.clone()));
595            self.sent = Some((now, Instant::now()));
596        }
597        out.iter()
598            .filter_map(|r| serde_json::to_string(r).ok())
599            .collect()
600    }
601
602    fn incoming(&mut self, text: &str) {
603        match serde_json::from_str::<LinkCommand>(text) {
604            Ok(LinkCommand::Devices { devices }) => {
605                crate::remote::devices::set_account(devices);
606            }
607            Ok(cmd) => (self.local.on_command)(cmd, CommandSource::Account),
608            Err(e) => log::warn!("link: not a command ({e}): {text}"),
609        }
610    }
611}
612
613/// This library's tracks for the server's ids, in the order given, and
614/// whether a sync ran to find them.
615///
616/// A koan server names a track by its uid, which this library adopted when it
617/// synced the track; another server by the id it issued. A server can name a
618/// track added since the last sync; if any are missing and `may_sync`, an
619/// incremental sync runs first, and whatever is still missing after it is left
620/// out. An id a sync already failed to find does not start another for a
621/// while: a command naming a track deleted on the server would otherwise sync
622/// every time it arrived.
623pub fn resolve_tracks(
624    db: &crate::db::connection::Database,
625    remote_ids: &[String],
626    may_sync: bool,
627) -> (Vec<i64>, bool) {
628    let lookup = |db: &crate::db::connection::Database| {
629        let mut stmt = db
630            .conn
631            .prepare_cached(
632                "SELECT id FROM tracks WHERE uid = ?1
633                 UNION ALL SELECT id FROM tracks WHERE remote_id = ?1 LIMIT 1",
634            )
635            .ok();
636        remote_ids
637            .iter()
638            .map(|rid| {
639                stmt.as_mut()
640                    .and_then(|s| s.query_row([rid], |r| r.get::<_, i64>(0)).ok())
641            })
642            .collect::<Vec<_>>()
643    };
644    let found = lookup(db);
645    let missing: Vec<&String> = remote_ids
646        .iter()
647        .zip(&found)
648        .filter(|(_, f)| f.is_none())
649        .map(|(id, _)| id)
650        .collect();
651    if missing.is_empty() || !may_sync || missing.iter().all(|id| recently_missed(id)) {
652        return (found.into_iter().flatten().collect(), false);
653    }
654    sync(db, false);
655    let found = lookup(db);
656    let mut missed = MISSED.lock();
657    let now = Instant::now();
658    missed.retain(|_, at| now.duration_since(*at) < MISS_TTL);
659    for (id, _) in remote_ids.iter().zip(&found).filter(|(_, f)| f.is_none()) {
660        missed.insert(id.clone(), now);
661    }
662    (found.into_iter().flatten().collect(), true)
663}
664
665/// Ids a sync looked for and did not find, and when.
666static MISSED: LazyLock<Mutex<HashMap<String, Instant>>> = LazyLock::new(Default::default);
667const MISS_TTL: Duration = Duration::from_secs(300);
668
669fn recently_missed(id: &str) -> bool {
670    MISSED
671        .lock()
672        .get(id)
673        .is_some_and(|at| at.elapsed() < MISS_TTL)
674}
675
676/// A sync from the configured server, as the app runs its own: the library,
677/// then favourites and playlists.
678pub fn sync(db: &crate::db::connection::Database, full: bool) {
679    let cfg = Config::load().unwrap_or_default();
680    if let Some(client) = subsonic_client(&cfg)
681        && let Err(e) = crate::helpers::sync_remote(
682            db,
683            &client,
684            full,
685            &cfg.remote.url,
686            &cfg.remote.username,
687            &|_| {},
688        )
689    {
690        log::warn!("link: sync failed: {e}");
691    }
692}
693
694/// A random id kept in the config directory.
695fn device_id(dir: &Path) -> String {
696    let path = dir.join("device-id");
697    if let Ok(id) = std::fs::read_to_string(&path) {
698        let id = id.trim();
699        if !id.is_empty() {
700            return id.to_string();
701        }
702    }
703    let id = uuid::Uuid::now_v7().to_string();
704    let _ = std::fs::create_dir_all(dir);
705    let _ = std::fs::write(&path, &id);
706    id
707}
708
709fn hostname() -> Option<String> {
710    let mut buf = [0u8; 256];
711    // SAFETY: the buffer is valid for its whole length, and gethostname
712    // writes at most that many bytes.
713    let ok = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } == 0;
714    if !ok {
715        return None;
716    }
717    let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
718    let name = String::from_utf8_lossy(&buf[..end]);
719    let name = name.trim_end_matches(".local").trim();
720    (!name.is_empty() && name != "localhost").then(|| name.to_string())
721}
722
723fn percent_encode(s: &str) -> String {
724    let mut out = String::with_capacity(s.len());
725    for b in s.bytes() {
726        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
727            out.push(b as char);
728        } else {
729            out.push_str(&format!("%{b:02X}"));
730        }
731    }
732    out
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    // Neither case may reach `sync`: a test has no business reading the
740    // machine's config and syncing against the server it names.
741    #[test]
742    fn unknown_ids_sync_only_when_allowed_and_not_recently_missed() {
743        let dir = tempfile::tempdir().unwrap();
744        let db = crate::db::connection::Database::open(&dir.path().join("koan.db")).unwrap();
745
746        let (found, synced) = resolve_tracks(&db, &["from-a-stranger".into()], false);
747        assert!(found.is_empty());
748        assert!(!synced, "a nearby peer's unknown id must not start a sync");
749
750        MISSED
751            .lock()
752            .insert("deleted-on-server".into(), Instant::now());
753        let (_, synced) = resolve_tracks(&db, &["deleted-on-server".into()], true);
754        assert!(
755            !synced,
756            "an id a sync just failed to find does not start another"
757        );
758    }
759
760    #[test]
761    fn commands_name_their_tracks() {
762        let play: LinkCommand =
763            serde_json::from_str(r#"{"type":"play","trackIds":["a","b"]}"#).unwrap();
764        assert_eq!(play.track_ids(), ["a", "b"]);
765        let jump: LinkCommand = serde_json::from_str(r#"{"type":"jumpTo","trackId":"c"}"#).unwrap();
766        assert_eq!(jump.track_ids(), ["c"]);
767        assert!(LinkCommand::Pause.track_ids().is_empty());
768    }
769
770    #[test]
771    fn a_push_token_is_a_tagged_report() {
772        let report = LinkReport::Push {
773            token: "ab12".into(),
774            sandbox: true,
775        };
776        let text = serde_json::to_string(&report).unwrap();
777        assert_eq!(text, r#"{"type":"push","token":"ab12","sandbox":true}"#);
778        assert_eq!(serde_json::from_str::<LinkReport>(&text).unwrap(), report);
779    }
780
781    #[test]
782    fn commands_are_tagged_json() {
783        let play = LinkCommand::Play {
784            track_ids: vec!["12".into(), "34".into()],
785            start_at: 1,
786            position_ms: 0,
787        };
788        let json = serde_json::to_string(&play).unwrap();
789        assert_eq!(
790            json,
791            r#"{"type":"play","trackIds":["12","34"],"startAt":1}"#
792        );
793        assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), play);
794        assert_eq!(
795            serde_json::from_str::<LinkCommand>(r#"{"type":"pause"}"#).unwrap(),
796            LinkCommand::Pause
797        );
798        let report = LinkReport::State(LinkState {
799            playing: true,
800            title: Some("Portions for Foxes".into()),
801            ..Default::default()
802        });
803        let json = serde_json::to_string(&report).unwrap();
804        assert!(json.starts_with(r#"{"type":"state","playing":true,"title":"Portions for Foxes""#));
805        assert_eq!(serde_json::from_str::<LinkReport>(&json).unwrap(), report);
806    }
807
808    #[test]
809    fn a_playhead_moving_on_time_is_not_news() {
810        let sent = LinkState {
811            playing: true,
812            position_ms: 10_000,
813            ..Default::default()
814        };
815        let later = |pos| LinkState {
816            position_ms: pos,
817            ..sent.clone()
818        };
819        let five = Duration::from_secs(5);
820        assert!(!later(15_000).differs(&sent, five));
821        assert!(later(60_000).differs(&sent, five), "a seek");
822        let paused = LinkState {
823            playing: false,
824            ..later(15_000)
825        };
826        assert!(paused.differs(&sent, five));
827    }
828
829    #[test]
830    fn the_url_follows_the_scheme_and_names_the_device() {
831        let identity = LinkIdentity {
832            name: "J's iPhone".into(),
833            platform: "ios".into(),
834            device_id: "abc".into(),
835        };
836        let url = link_url(
837            &SubsonicAuth::new("https://music.example.com", "j", "pw"),
838            &identity,
839        )
840        .unwrap();
841        assert!(url.starts_with("wss://music.example.com/rest/koanLink?"));
842        assert!(url.contains("client=J%27s%20iPhone"));
843        assert!(url.contains("device=abc"));
844        assert!(url.contains("devices=1"));
845        assert!(
846            link_url(&SubsonicAuth::new("http://h:4000", "j", "pw"), &identity)
847                .unwrap()
848                .starts_with("ws://h:4000/")
849        );
850    }
851
852    #[test]
853    fn a_relayed_command_nests_the_command() {
854        let report = LinkReport::Command {
855            to: "phone".into(),
856            command: LinkCommand::HandOff { to: "mac".into() },
857        };
858        let json = serde_json::to_string(&report).unwrap();
859        assert_eq!(
860            json,
861            r#"{"type":"command","to":"phone","command":{"type":"handOff","to":"mac"}}"#
862        );
863        assert_eq!(serde_json::from_str::<LinkReport>(&json).unwrap(), report);
864    }
865
866    #[test]
867    fn an_older_queue_entry_still_reads() {
868        let e: LinkQueueEntry =
869            serde_json::from_str(r#"{"trackId":"7","title":"t","artist":"a","current":true}"#)
870                .unwrap();
871        assert_eq!(e.id, None);
872        assert_eq!(e.duration_ms, 0);
873    }
874
875    #[test]
876    fn strangers_cannot_touch_the_library() {
877        assert!(LinkCommand::Pause.allowed_nearby());
878        assert!(LinkCommand::HandOff { to: "x".into() }.allowed_nearby());
879        assert!(!LinkCommand::Sync { full: true }.allowed_nearby());
880        assert!(!LinkCommand::Evict { track_ids: vec![] }.allowed_nearby());
881    }
882
883    #[test]
884    fn the_device_id_is_kept() {
885        let dir = tempfile::tempdir().unwrap();
886        let first = device_id(dir.path());
887        assert_eq!(device_id(dir.path()), first);
888    }
889}