koan-core 0.60.10

Core library for koan — bit-perfect music player. Audio engine, player, database, format strings.
Documentation
//! What the server this client signs in to turns out to be.
//!
//! koan's own additions to Subsonic are listed as OpenSubsonic extensions, and
//! a client decides what to offer from that list rather than from the server
//! calling itself koan: a koan server older than an extension does not list it,
//! and a server that is not koan can list one (the hub puts devices in front of
//! Navidrome). Probed once per sign-in and kept.

use parking_lot::Mutex;
use serde::{Deserialize, Serialize};

use crate::remote::client::SubsonicAuth;

/// The standing connection a server can command a client over:
/// `/rest/koanLink`. See `remote::link`.
pub const LINK: &str = "koanLink";
/// A person's devices seeing and commanding each other through the server:
/// the account's other devices sent down the link, commands relayed between
/// them, handoff, and `/rest/koanCommand` for a device whose link is down.
pub const DEVICES: &str = "koanDevices";
/// Invite links: `/rest/koanJoin` trades the token one carries for an API
/// key. See `crate::invite`.
pub const INVITE: &str = "koanInvite";
/// Sharing a device with other accounts on the server: `LinkReport::Share`
/// from the device, `LinkCommand::Shares` back, and shared devices in a
/// grantee's list.
pub const SHARES: &str = "koanShares";
/// Signing in a device without a keyboard: `/rest/koanPair` holds the device
/// open until someone approves it with `/rest/koanPairApprove`. See
/// `remote::pair`.
pub const PAIR: &str = "koanPair";
/// The account's play history as the record every device reads:
/// `/rest/koanHistory` pages it, `/rest/koanForgetPlays` forgets from it, and
/// `LinkCommand::HistoryChanged` says when it moved. See `remote::history`.
pub const HISTORY: &str = "koanHistory";
/// Signing in with a password ends in an API key: `/rest/koanSignIn`, signed
/// with the account's own password, makes a key for the device, which keeps
/// the key and not the password. See `helpers::set_remote_credentials`.
pub const SIGN_IN: &str = "koanSignIn";
/// Setting passwords: `/rest/koanSetUserPassword`, for an admin any account's
/// and for anyone their own, given the current one.
pub const PASSWORDS: &str = "koanPasswords";
/// The account's own API keys: `/rest/koanApiKeys` lists them (never the
/// keys themselves), `/rest/koanCreateApiKey` and `/rest/koanRevokeApiKey`
/// make and revoke one.
pub const API_KEYS: &str = "koanApiKeys";
/// Assistants connect to this server's `/mcp`: `/rest/koanMcp` gives its
/// address and the page explaining how. Listed only where the server knows
/// the address it is reached at (`sharing.public_url`), which MCP's sign-in
/// needs.
pub const MCP: &str = "koanMcp";

/// The server relays devices' answers to commands sent with an id, and
/// answers `koanCommand` with how it went: see `remote::acks`.
pub const ACK: &str = "koanAck";

/// Devices proving to each other on the local network that they belong to
/// the same account: a linking device registers its public key with
/// `deviceKey`, and links that did are sent the account's keys as
/// `LinkCommand::DeviceKeys`.
pub const DEVICE_KEYS: &str = "koanDeviceKeys";

/// The account's scrobbling, set up from an app: `/rest/koanScrobbling`
/// reports it, `/rest/koanScrobblingConnect` checks and stores a ListenBrainz
/// token, `/rest/koanScrobblingDisconnect` removes it. See `remote::scrobbling`.
pub const SCROBBLING: &str = "koanScrobbling";
/// The account's EQ profiles kept everywhere: `/rest/koanDspProfiles` reads
/// what changed after a cursor, `/rest/koanDspProfileSave` and
/// `/rest/koanDspProfileDelete` change one, `/rest/koanDspFile` moves the
/// files a profile names, and `/rest/koanDspDismiss` turns an AutoEQ
/// suggestion down everywhere. `LinkCommand::DspProfilesChanged` says when
/// they moved. See `remote::dsp_sync`.
pub const DSP_PROFILES: &str = "koanDspProfiles";

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerProfile {
    /// OpenSubsonic's `type`: `koan`, `navidrome`. `None` from a server that
    /// predates OpenSubsonic.
    pub kind: Option<String>,
    pub version: Option<String>,
    pub open_subsonic: bool,
    /// Each extension's name and the versions offered.
    pub extensions: Vec<(String, Vec<i64>)>,
}

impl ServerProfile {
    pub fn offers(&self, extension: &str) -> bool {
        self.extensions.iter().any(|(name, _)| name == extension)
    }

    /// Whether a client should link. koan servers linked before the link was
    /// listed as an extension, so the name still counts.
    pub fn links(&self) -> bool {
        self.offers(LINK) || self.kind.as_deref() == Some("koan")
    }
}

/// The last profile probed, whose credentials it was for, and whether it is
/// still to be trusted without asking again.
static PROFILE: Mutex<Option<(SubsonicAuth, ServerProfile, bool)>> = Mutex::new(None);

/// The profile of the server `auth` signs in to, probing it on first ask.
/// `None` while it cannot be reached; asked again next time.
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));
    crate::remote::devices::touch();
    Some(profile)
}

/// Probe again on next ask: the server may have changed under a dropped link.
/// What was found stays on show until then.
pub fn forget() {
    if let Some(held) = PROFILE.lock().as_mut() {
        held.2 = false;
    }
}

/// The configured server's profile as last probed, without asking it.
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())
}

/// Whether the configured server is koan, probing it if it has not been.
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());
    }
}