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/// Invite links: `/rest/koanJoin` trades the token one carries for an API
22/// key. See `crate::invite`.
23pub const INVITE: &str = "koanInvite";
24
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ServerProfile {
27    /// OpenSubsonic's `type`: `koan`, `navidrome`. `None` from a server that
28    /// predates OpenSubsonic.
29    pub kind: Option<String>,
30    pub version: Option<String>,
31    pub open_subsonic: bool,
32    /// Each extension's name and the versions offered.
33    pub extensions: Vec<(String, Vec<i64>)>,
34}
35
36impl ServerProfile {
37    pub fn offers(&self, extension: &str) -> bool {
38        self.extensions.iter().any(|(name, _)| name == extension)
39    }
40
41    /// Whether a client should link. koan servers linked before the link was
42    /// listed as an extension, so the name still counts.
43    pub fn links(&self) -> bool {
44        self.offers(LINK) || self.kind.as_deref() == Some("koan")
45    }
46}
47
48/// The last profile probed, whose credentials it was for, and whether it is
49/// still to be trusted without asking again.
50static PROFILE: Mutex<Option<(SubsonicAuth, ServerProfile, bool)>> = Mutex::new(None);
51
52/// The profile of the server `auth` signs in to, probing it on first ask.
53/// `None` while it cannot be reached; asked again next time.
54pub fn for_auth(auth: &SubsonicAuth) -> Option<ServerProfile> {
55    if let Some((held, profile, true)) = PROFILE.lock().as_ref()
56        && held == auth
57    {
58        return Some(profile.clone());
59    }
60    let client = crate::remote::client::SubsonicClient::from_auth(auth.clone());
61    let profile = match client.profile() {
62        Ok(p) => p,
63        Err(e) => {
64            log::info!("profile: {} did not answer: {e}", auth.base_url);
65            return None;
66        }
67    };
68    log::info!(
69        "profile: {} is {} {} ({} extensions)",
70        auth.base_url,
71        profile.kind.as_deref().unwrap_or("a Subsonic server"),
72        profile.version.as_deref().unwrap_or(""),
73        profile.extensions.len()
74    );
75    *PROFILE.lock() = Some((auth.clone(), profile.clone(), true));
76    crate::remote::devices::touch();
77    Some(profile)
78}
79
80/// Probe again on next ask: the server may have changed under a dropped link.
81/// What was found stays on show until then.
82pub fn forget() {
83    if let Some(held) = PROFILE.lock().as_mut() {
84        held.2 = false;
85    }
86}
87
88/// The configured server's profile as last probed, without asking it.
89pub fn current() -> Option<ServerProfile> {
90    let cfg = crate::config::Config::load().unwrap_or_default();
91    let auth = crate::helpers::subsonic_auth(&cfg)?;
92    PROFILE
93        .lock()
94        .as_ref()
95        .filter(|(held, _, _)| *held == auth)
96        .map(|(_, p, _)| p.clone())
97}
98
99/// Whether the configured server is koan, probing it if it has not been.
100pub fn is_koan(auth: &SubsonicAuth) -> bool {
101    for_auth(auth).is_some_and(|p| p.kind.as_deref() == Some("koan"))
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn an_older_koan_server_still_links() {
110        let old = ServerProfile {
111            kind: Some("koan".into()),
112            open_subsonic: true,
113            ..Default::default()
114        };
115        assert!(old.links());
116        assert!(!old.offers(DEVICES));
117        let hub = ServerProfile {
118            kind: Some("navidrome".into()),
119            extensions: vec![(LINK.into(), vec![1]), (DEVICES.into(), vec![1])],
120            ..Default::default()
121        };
122        assert!(hub.links() && hub.offers(DEVICES));
123        assert!(!ServerProfile::default().links());
124    }
125}