selene-core 0.6.0

selene-core is the backend for Selene, a local-first music player
Documentation
use std::{fmt::Display, fs, ops::Deref, sync::OnceLock};

use lunar_lib::prompts::{CliPrompter, Prompter};
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{REQWEST_CLIENT, data_dir};

mod error;
pub use error::*;

pub const LASTFM_API_KEY: &str = "aeffe4a6d9c3b1d996c8442f473d4de4";
pub const LASTFM_API_SECRET: &str = "7953741c2b6857dddf5833de7e7d14b0";

const LASTFM_SCROBBLER_ORIGIN: &str = "http://ws.audioscrobbler.com/2.0";
const LASTFM_ORIGIN: &str = "http://www.last.fm/api";

static LASTFM_CLIENT: OnceLock<LastFmClient> = OnceLock::new();
pub async fn initialize_last_fm_client() -> bool {
    if LASTFM_CLIENT.get().is_some() {
        return true;
    }

    if let Some(flatten) = LastFmClient::load().await.ok().flatten() {
        let _ = LASTFM_CLIENT.set(flatten);
        return true;
    }

    false
}

pub fn last_fm_client() -> Option<&'static LastFmClient> {
    LASTFM_CLIENT.get()
}

mod api_methods;
pub use api_methods::*;

#[derive(Serialize, Deserialize)]
pub struct LastFmClient {
    name: String,
    key: String,
    subscriber: bool,
}

#[derive(Debug, Error)]
pub enum LastFmClientError {
    #[error("{0}")]
    Io(#[from] std::io::Error),

    #[error("{0}")]
    LastFmError(#[from] LastFmError),

    #[error("{0}")]
    LastFmMethodError(#[from] LastFmMethodError),

    #[error("{0}")]
    Keyring(#[from] keyring::Error),
}

impl LastFmClient {
    async fn request_from_token(token: &LastFmToken) -> Result<LastFmClient, LastFmError> {
        let mut query = vec![
            ("method", "auth.getsession"),
            ("api_key", LASTFM_API_KEY),
            ("token", token),
        ];

        let sig = request_sig(&query);
        query.push(("api_sig", &*sig));
        query.push(("format", "json"));

        #[derive(Deserialize)]
        struct Inner {
            name: String,
            key: String,
            subscriber: isize,
        }

        #[derive(Deserialize)]
        struct Wrapper {
            session: Inner,
        }

        let wrapper = REQWEST_CLIENT
            .get(LASTFM_SCROBBLER_ORIGIN)
            .query(&query)
            .send()
            .await
            .unwrap()
            .json::<LastFmResult<Wrapper>>()
            .await
            .unwrap()
            .into_result()?;

        Ok(Self {
            name: wrapper.session.name,
            key: wrapper.session.key,
            subscriber: wrapper.session.subscriber > 0,
        })
    }

    /// Returns a [`LastFmClient`] used for doing authorized requests to Last.fm
    ///
    /// `on_wait` is called after the authorization webpage is opened. This function should return when the user has authorized the token on the webpage
    ///
    /// # Errors
    ///
    /// Errors with a [`LastFmError`] if Last.fm API returns an error
    pub async fn connect<F>(on_wait: F) -> Result<LastFmClient, LastFmError>
    where
        F: FnOnce(),
    {
        let token = get_token().await?;

        open_auth_page(&token);
        on_wait();

        Self::request_from_token(&token).await
    }

    /// Saves client information to the system keyring. Will ask to fallback to a plaintext file on failure
    pub async fn save(&self) -> Result<(), LastFmClientError> {
        let json = serde_json::to_string(self).expect("Serialization should not fail");

        let keyring_json = json.clone();
        let result = tokio::task::spawn_blocking(move || -> Result<(), keyring::Error> {
            keyring::Entry::new("selene", "lastfm")?.set_password(&keyring_json)
        })
        .await
        .unwrap();

        if let Err(err) = result {
            let prompter = CliPrompter;
            let yes = prompter.ask(format_args!("The session key could not be saved in the systems keyring (Error: {err}). Would you like to save it as a plaintext file in Selene's data folder?'"), false).unwrap_or(false);

            if yes {
                fs::write(data_dir().join("lastfm_session.json"), &json)?
            }
        }

        Ok(())
    }

    /// Loads client information from the system keyring, falling back to the plaintext file
    pub async fn load() -> Result<Option<LastFmClient>, LastFmClientError> {
        let result = tokio::task::spawn_blocking(|| -> Result<String, keyring::Error> {
            keyring::Entry::new("selene", "lastfm")?.get_password()
        })
        .await
        .unwrap();

        match result {
            Ok(json) => {
                let client: LastFmClient =
                    serde_json::from_str(&json).expect("LastFM Client data was modified");
                Ok(Some(client))
            }
            Err(keyring::Error::NoEntry) => {
                let key_file = data_dir().join("lastfm_session.json");

                if !key_file.exists() {
                    return Ok(None);
                }

                let json = fs::read_to_string(&key_file)?;
                let client: LastFmClient =
                    serde_json::from_str(&json).expect("LastFM Client data was modified");
                Ok(Some(client))
            }
            Err(err) => Err(err.into()),
        }
    }
}

fn request_sig<K, V>(params: &[(K, V)]) -> String
where
    K: AsRef<str>,
    V: AsRef<str>,
{
    let mut params: Vec<(&str, &str)> = params
        .iter()
        .map(|(k, v)| (k.as_ref(), v.as_ref()))
        .collect();

    params.sort_by_key(|(k, _)| *k);

    let mut seed: String = params.iter().map(|(k, v)| format!("{k}{v}")).collect();
    seed.push_str(LASTFM_API_SECRET);

    format!("{:x}", md5::compute(seed))
}

#[derive(Deserialize)]
pub struct LastFmToken {
    token: String,
}

impl Display for LastFmToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.token.fmt(f)
    }
}

impl Deref for LastFmToken {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        &self.token
    }
}

async fn get_token() -> Result<LastFmToken, LastFmError> {
    let query = [
        ("method", "auth.gettoken"),
        ("api_key", LASTFM_API_KEY),
        ("format", "json"),
    ];

    REQWEST_CLIENT
        .get(LASTFM_SCROBBLER_ORIGIN)
        .query(&query)
        .send()
        .await
        .unwrap()
        .json::<LastFmResult<LastFmToken>>()
        .await
        .unwrap()
        .into_result()
}

fn open_auth_page(token: &LastFmToken) {
    let page = format!("{LASTFM_ORIGIN}/auth/?api_key={LASTFM_API_KEY}&token={token}");
    open::that_detached(page).expect("Failed to open webpage");
}