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/// Sharing a device with other accounts on the server: `LinkReport::Share`
25/// from the device, `LinkCommand::Shares` back, and shared devices in a
26/// grantee's list.
27pub const SHARES: &str = "koanShares";
28/// Signing in a device without a keyboard: `/rest/koanPair` holds the device
29/// open until someone approves it with `/rest/koanPairApprove`. See
30/// `remote::pair`.
31pub const PAIR: &str = "koanPair";
32/// The account's play history as the record every device reads:
33/// `/rest/koanHistory` pages it, `/rest/koanForgetPlays` forgets from it, and
34/// `LinkCommand::HistoryChanged` says when it moved. See `remote::history`.
35pub const HISTORY: &str = "koanHistory";
36/// Signing in with a password ends in an API key: `/rest/koanSignIn`, signed
37/// with the account's own password, makes a key for the device, which keeps
38/// the key and not the password. See `helpers::set_remote_credentials`.
39pub const SIGN_IN: &str = "koanSignIn";
40/// Setting passwords: `/rest/koanSetUserPassword`, for an admin any account's
41/// and for anyone their own, given the current one.
42pub const PASSWORDS: &str = "koanPasswords";
43/// The account's own API keys: `/rest/koanApiKeys` lists them (never the
44/// keys themselves), `/rest/koanCreateApiKey` and `/rest/koanRevokeApiKey`
45/// make and revoke one.
46pub const API_KEYS: &str = "koanApiKeys";
47/// The account's own app passwords, for Subsonic apps that sign in only with
48/// a token: `/rest/koanAppPasswords` lists them (never the passwords),
49/// `/rest/koanCreateAppPassword` and `/rest/koanRevokeAppPassword` make and
50/// revoke one. Listed only where the server can seal them.
51pub const APP_PASSWORDS: &str = "koanAppPasswords";
52/// Assistants connect to this server's `/mcp`: `/rest/koanMcp` gives its
53/// address and the page explaining how. Listed only where the server knows
54/// the address it is reached at (`sharing.public_url`), which MCP's sign-in
55/// needs.
56pub const MCP: &str = "koanMcp";
57
58/// The server relays devices' answers to commands sent with an id, and
59/// answers `koanCommand` with how it went: see `remote::acks`.
60pub const ACK: &str = "koanAck";
61
62/// Devices proving to each other on the local network that they belong to
63/// the same account: a linking device registers its public key with
64/// `deviceKey`, and links that did are sent the account's keys as
65/// `LinkCommand::DeviceKeys`.
66pub const DEVICE_KEYS: &str = "koanDeviceKeys";
67
68/// The account's scrobbling, set up from an app: `/rest/koanScrobbling`
69/// reports it, `/rest/koanScrobblingConnect` checks and stores a ListenBrainz
70/// token, `/rest/koanScrobblingDisconnect` removes it. See `remote::scrobbling`.
71pub const SCROBBLING: &str = "koanScrobbling";
72/// The account's EQ profiles kept everywhere: `/rest/koanDspProfiles` reads
73/// what changed after a cursor, `/rest/koanDspProfileSave` and
74/// `/rest/koanDspProfileDelete` change one, `/rest/koanDspFile` moves the
75/// files a profile names, and `/rest/koanDspDismiss` turns an AutoEQ
76/// suggestion down everywhere. `LinkCommand::DspProfilesChanged` says when
77/// they moved. See `remote::dsp_sync`.
78pub const DSP_PROFILES: &str = "koanDspProfiles";
79/// EQ profiles deleted in the last thirty days, which the server keeps:
80/// `/rest/koanDspDeleted` lists the account's, `/rest/koanDspRestore` saves
81/// one again as a new edit, which every device then adopts.
82pub const DSP_DELETED: &str = "koanDspDeleted";
83
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
85pub struct ServerProfile {
86 /// OpenSubsonic's `type`: `koan`, `navidrome`. `None` from a server that
87 /// predates OpenSubsonic.
88 pub kind: Option<String>,
89 pub version: Option<String>,
90 pub open_subsonic: bool,
91 /// Each extension's name and the versions offered.
92 pub extensions: Vec<(String, Vec<i64>)>,
93}
94
95impl ServerProfile {
96 pub fn offers(&self, extension: &str) -> bool {
97 self.extensions.iter().any(|(name, _)| name == extension)
98 }
99
100 /// Whether a client should link. koan servers linked before the link was
101 /// listed as an extension, so the name still counts.
102 pub fn links(&self) -> bool {
103 self.offers(LINK) || self.kind.as_deref() == Some("koan")
104 }
105}
106
107/// The last profile probed, whose credentials it was for, and whether it is
108/// still to be trusted without asking again.
109static PROFILE: Mutex<Option<(SubsonicAuth, ServerProfile, bool)>> = Mutex::new(None);
110
111/// The profile of the server `auth` signs in to, probing it on first ask.
112/// `None` while it cannot be reached; asked again next time.
113pub fn for_auth(auth: &SubsonicAuth) -> Option<ServerProfile> {
114 if let Some((held, profile, true)) = PROFILE.lock().as_ref()
115 && held == auth
116 {
117 return Some(profile.clone());
118 }
119 let client = crate::remote::client::SubsonicClient::from_auth(auth.clone());
120 let probed = client.profile();
121 crate::remote::refusal::observe(auth, &probed);
122 let profile = match probed {
123 Ok(p) => p,
124 Err(e) => {
125 log::info!("profile: {} did not answer: {e}", auth.base_url);
126 return None;
127 }
128 };
129 log::info!(
130 "profile: {} is {} {} ({} extensions)",
131 auth.base_url,
132 profile.kind.as_deref().unwrap_or("a Subsonic server"),
133 profile.version.as_deref().unwrap_or(""),
134 profile.extensions.len()
135 );
136 *PROFILE.lock() = Some((auth.clone(), profile.clone(), true));
137 crate::remote::devices::touch();
138 Some(profile)
139}
140
141/// Probe again on next ask: the server may have changed under a dropped link.
142/// What was found stays on show until then.
143pub fn forget() {
144 if let Some(held) = PROFILE.lock().as_mut() {
145 held.2 = false;
146 }
147}
148
149/// The configured server's profile as last probed, without asking it.
150pub fn current() -> Option<ServerProfile> {
151 let cfg = crate::config::Config::load().unwrap_or_default();
152 let auth = crate::helpers::subsonic_auth(&cfg)?;
153 PROFILE
154 .lock()
155 .as_ref()
156 .filter(|(held, _, _)| *held == auth)
157 .map(|(_, p, _)| p.clone())
158}
159
160/// Whether the configured server is koan, probing it if it has not been.
161pub fn is_koan(auth: &SubsonicAuth) -> bool {
162 for_auth(auth).is_some_and(|p| p.kind.as_deref() == Some("koan"))
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn an_older_koan_server_still_links() {
171 let old = ServerProfile {
172 kind: Some("koan".into()),
173 open_subsonic: true,
174 ..Default::default()
175 };
176 assert!(old.links());
177 assert!(!old.offers(DEVICES));
178 let hub = ServerProfile {
179 kind: Some("navidrome".into()),
180 extensions: vec![(LINK.into(), vec![1]), (DEVICES.into(), vec![1])],
181 ..Default::default()
182 };
183 assert!(hub.links() && hub.offers(DEVICES));
184 assert!(!ServerProfile::default().links());
185 }
186}