use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use crate::remote::client::SubsonicAuth;
pub const LINK: &str = "koanLink";
pub const DEVICES: &str = "koanDevices";
pub const INVITE: &str = "koanInvite";
pub const SHARES: &str = "koanShares";
pub const PAIR: &str = "koanPair";
pub const HISTORY: &str = "koanHistory";
pub const SIGN_IN: &str = "koanSignIn";
pub const PASSWORDS: &str = "koanPasswords";
pub const API_KEYS: &str = "koanApiKeys";
pub const APP_PASSWORDS: &str = "koanAppPasswords";
pub const MCP: &str = "koanMcp";
pub const APNS: &str = "koanApns";
pub const TV_PAIRING: &str = "koanTvPairing";
pub const ACK: &str = "koanAck";
pub const DEVICE_KEYS: &str = "koanDeviceKeys";
pub const SCROBBLING: &str = "koanScrobbling";
pub const DSP_PROFILES: &str = "koanDspProfiles";
pub const DSP_DELETED: &str = "koanDspDeleted";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerProfile {
pub kind: Option<String>,
pub version: Option<String>,
pub open_subsonic: bool,
pub extensions: Vec<(String, Vec<i64>)>,
pub url: Option<String>,
}
impl ServerProfile {
pub fn offers(&self, extension: &str) -> bool {
self.extensions.iter().any(|(name, _)| name == extension)
}
pub fn links(&self) -> bool {
self.offers(LINK) || self.kind.as_deref() == Some("koan")
}
}
static PROFILE: Mutex<Option<(SubsonicAuth, ServerProfile, bool)>> = Mutex::new(None);
pub fn for_auth(auth: &SubsonicAuth) -> Option<ServerProfile> {
if let Some((held, profile, true)) = PROFILE.lock().as_ref()
&& held == auth
{
return Some(profile.clone());
}
let client = crate::remote::client::SubsonicClient::from_auth(auth.clone());
let probed = client.profile();
crate::remote::refusal::observe(auth, &probed);
let profile = match probed {
Ok(p) => p,
Err(e) => {
log::info!("profile: {} did not answer: {e}", auth.base_url);
return None;
}
};
log::info!(
"profile: {} is {} {} ({} extensions)",
auth.base_url,
profile.kind.as_deref().unwrap_or("a Subsonic server"),
profile.version.as_deref().unwrap_or(""),
profile.extensions.len()
);
*PROFILE.lock() = Some((auth.clone(), profile.clone(), true));
adopt_url(auth, profile.url.as_deref());
crate::remote::devices::touch();
Some(profile)
}
fn adopt_url(auth: &SubsonicAuth, url: Option<&str>) {
use crate::config::Config;
let url = url.map(|u| u.trim().trim_end_matches('/')).unwrap_or("");
let Ok(cfg) = Config::load() else { return };
if crate::helpers::subsonic_auth(&cfg).as_ref() != Some(auth) || cfg.remote.server_url == url {
return;
}
if let Err(e) = Config::persist(|c| c.remote.server_url = url.to_owned()) {
log::warn!("profile: could not keep the server's address: {e}");
}
}
pub fn forget() {
if let Some(held) = PROFILE.lock().as_mut() {
held.2 = false;
}
}
pub fn current() -> Option<ServerProfile> {
let cfg = crate::config::Config::load().unwrap_or_default();
let auth = crate::helpers::subsonic_auth(&cfg)?;
PROFILE
.lock()
.as_ref()
.filter(|(held, _, _)| *held == auth)
.map(|(_, p, _)| p.clone())
}
pub fn lacking(profile: Option<&ServerProfile>, to: &str) -> String {
match profile {
None => format!("could not reach the server to see whether it can {to}"),
Some(p) if p.kind.as_deref() == Some("koan") => {
format!("this server is older than this app: update it to {to}")
}
Some(p) if p.kind.is_none() => format!("this server cannot {to}"),
Some(_) => format!("only a kōan server can {to}"),
}
}
pub fn is_koan(auth: &SubsonicAuth) -> bool {
for_auth(auth).is_some_and(|p| p.kind.as_deref() == Some("koan"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_older_koan_server_still_links() {
let old = ServerProfile {
kind: Some("koan".into()),
open_subsonic: true,
..Default::default()
};
assert!(old.links());
assert!(!old.offers(DEVICES));
let hub = ServerProfile {
kind: Some("navidrome".into()),
extensions: vec![(LINK.into(), vec![1]), (DEVICES.into(), vec![1])],
..Default::default()
};
assert!(hub.links() && hub.offers(DEVICES));
assert!(!ServerProfile::default().links());
}
#[test]
fn a_missing_feature_names_its_cause() {
let koan = ServerProfile {
kind: Some("koan".into()),
..Default::default()
};
let navidrome = ServerProfile {
kind: Some("navidrome".into()),
..Default::default()
};
assert_eq!(
lacking(Some(&koan), "invite people"),
"this server is older than this app: update it to invite people"
);
assert_eq!(
lacking(Some(&navidrome), "invite people"),
"only a kōan server can invite people"
);
assert_eq!(
lacking(Some(&ServerProfile::default()), "invite people"),
"this server cannot invite people"
);
assert!(lacking(None, "invite people").starts_with("could not reach"));
}
}