Skip to main content

koan_core/remote/
profile.rs

1//! What the server this client signs in to turns out to be.
2//!
3//! koan's own additions to Subsonic are listed as OpenSubsonic extensions, and
4//! a client decides what to offer from that list rather than from the server
5//! calling itself koan: a koan server older than an extension does not list it,
6//! and a server that is not koan can list one (the hub puts devices in front of
7//! Navidrome). Probed once per sign-in and kept.
8
9use parking_lot::Mutex;
10use serde::{Deserialize, Serialize};
11
12use crate::remote::client::SubsonicAuth;
13
14/// The standing connection a server can command a client over:
15/// `/rest/koanLink`. See `remote::link`.
16pub const LINK: &str = "koanLink";
17/// A person's devices seeing and commanding each other through the server:
18/// the account's other devices sent down the link, commands relayed between
19/// them, handoff, and `/rest/koanCommand` for a device whose link is down.
20pub const DEVICES: &str = "koanDevices";
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ServerProfile {
24    /// OpenSubsonic's `type`: `koan`, `navidrome`. `None` from a server that
25    /// predates OpenSubsonic.
26    pub kind: Option<String>,
27    pub version: Option<String>,
28    pub open_subsonic: bool,
29    /// Each extension's name and the versions offered.
30    pub extensions: Vec<(String, Vec<i64>)>,
31}
32
33impl ServerProfile {
34    pub fn offers(&self, extension: &str) -> bool {
35        self.extensions.iter().any(|(name, _)| name == extension)
36    }
37
38    /// Whether a client should link. koan servers linked before the link was
39    /// listed as an extension, so the name still counts.
40    pub fn links(&self) -> bool {
41        self.offers(LINK) || self.kind.as_deref() == Some("koan")
42    }
43}
44
45/// The last profile probed, whose credentials it was for, and whether it is
46/// still to be trusted without asking again.
47static PROFILE: Mutex<Option<(SubsonicAuth, ServerProfile, bool)>> = Mutex::new(None);
48
49/// The profile of the server `auth` signs in to, probing it on first ask.
50/// `None` while it cannot be reached; asked again next time.
51pub fn for_auth(auth: &SubsonicAuth) -> Option<ServerProfile> {
52    if let Some((held, profile, true)) = PROFILE.lock().as_ref()
53        && held == auth
54    {
55        return Some(profile.clone());
56    }
57    let client = crate::remote::client::SubsonicClient::from_auth(auth.clone());
58    let profile = match client.profile() {
59        Ok(p) => p,
60        Err(e) => {
61            log::info!("profile: {} did not answer: {e}", auth.base_url);
62            return None;
63        }
64    };
65    log::info!(
66        "profile: {} is {} {} ({} extensions)",
67        auth.base_url,
68        profile.kind.as_deref().unwrap_or("a Subsonic server"),
69        profile.version.as_deref().unwrap_or(""),
70        profile.extensions.len()
71    );
72    *PROFILE.lock() = Some((auth.clone(), profile.clone(), true));
73    crate::remote::devices::touch();
74    Some(profile)
75}
76
77/// Probe again on next ask: the server may have changed under a dropped link.
78/// What was found stays on show until then.
79pub fn forget() {
80    if let Some(held) = PROFILE.lock().as_mut() {
81        held.2 = false;
82    }
83}
84
85/// The configured server's profile as last probed, without asking it.
86pub fn current() -> Option<ServerProfile> {
87    let cfg = crate::config::Config::load().unwrap_or_default();
88    let auth = crate::helpers::subsonic_auth(&cfg)?;
89    PROFILE
90        .lock()
91        .as_ref()
92        .filter(|(held, _, _)| *held == auth)
93        .map(|(_, p, _)| p.clone())
94}
95
96/// Whether the configured server is koan, probing it if it has not been.
97pub fn is_koan(auth: &SubsonicAuth) -> bool {
98    for_auth(auth).is_some_and(|p| p.kind.as_deref() == Some("koan"))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn an_older_koan_server_still_links() {
107        let old = ServerProfile {
108            kind: Some("koan".into()),
109            open_subsonic: true,
110            ..Default::default()
111        };
112        assert!(old.links());
113        assert!(!old.offers(DEVICES));
114        let hub = ServerProfile {
115            kind: Some("navidrome".into()),
116            extensions: vec![(LINK.into(), vec![1]), (DEVICES.into(), vec![1])],
117            ..Default::default()
118        };
119        assert!(hub.links() && hub.offers(DEVICES));
120        assert!(!ServerProfile::default().links());
121    }
122}