spotify_player 0.24.1

A Spotify player in the terminal with full feature parity
use librespot_core::session::Session;
use maybe_async::maybe_async;
use rspotify::{
    clients::{BaseClient, OAuthClient},
    http::HttpClient,
    sync::Mutex,
    AuthCodePkceSpotify, ClientResult, Config, Credentials, OAuth, Token,
};
use std::{fmt, sync::Arc};

use crate::token;

#[derive(Clone, Default)]
/// A custom Spotify client to interact with the official Spotify API server
pub struct Spotify {
    creds: Credentials,
    oauth: OAuth,
    config: Config,
    token: Arc<Mutex<Option<Token>>>,
    http: HttpClient,
    session: Arc<tokio::sync::Mutex<Option<Session>>>,
}

#[allow(clippy::missing_fields_in_debug)] // Seems like not all fields are necessary in debug
impl fmt::Debug for Spotify {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Spotify")
            .field("creds", &self.creds)
            .field("oauth", &self.oauth)
            .field("config", &self.config)
            .field("token", &self.token)
            .finish()
    }
}

impl Spotify {
    /// Create a new Spotify client
    pub fn new() -> Spotify {
        Self {
            creds: Credentials::default(),
            oauth: OAuth::default(),
            config: Config {
                token_refreshing: true,
                ..Default::default()
            },
            token: Arc::new(Mutex::new(None)),
            http: HttpClient::default(),
            session: Arc::new(tokio::sync::Mutex::new(None)),
        }
    }

    pub async fn set_session(&self, session: Session) {
        *self.session.lock().await = Some(session);
    }

    pub async fn session(&self) -> Session {
        self.session
            .lock()
            .await
            .clone()
            .expect("non-empty Spotify session")
    }
}

// TODO: remove the below uses of `maybe_async` crate once
// async trait is fully supported in stable Rust.

#[maybe_async]
impl BaseClient for Spotify {
    fn get_http(&self) -> &HttpClient {
        &self.http
    }

    fn get_token(&self) -> Arc<Mutex<Option<Token>>> {
        Arc::clone(&self.token)
    }

    fn get_creds(&self) -> &Credentials {
        &self.creds
    }

    fn get_config(&self) -> &Config {
        &self.config
    }

    async fn refetch_token(&self) -> ClientResult<Option<Token>> {
        let session = self.session().await;
        let old_token = self.token.lock().await.unwrap().clone();

        if session.is_invalid() {
            tracing::error!("Failed to get a new token: invalid session");
            return Ok(old_token);
        }

        match token::get_token_rspotify(&session).await {
            Ok(token) => Ok(Some(token)),
            Err(err) => {
                tracing::error!("Failed to get a new token: {err:#}");
                Ok(old_token)
            }
        }
    }
}

/// Implement `OAuthClient` trait for `Spotify` struct
/// to allow calling methods that get/modify user's data such as
/// `current_user_playlists`, `playlist_add_items`, etc.
///
/// Because the `Spotify` client interacts with Spotify APIs
/// using an access token that is manually retrieved by
/// the `librespot::get_token` function, implementing
/// `OAuthClient::get_oauth` and `OAuthClient::request_token` is unnecessary
#[maybe_async]
impl OAuthClient for Spotify {
    fn get_oauth(&self) -> &OAuth {
        panic!("`OAuthClient::get_oauth` should never be called!")
    }

    async fn request_token(&self, _code: &str) -> ClientResult<()> {
        panic!("`OAuthClient::request_token` should never be called!")
    }
}

/// Thin wrapper over [`rspotify::AuthCodePkceSpotify`] for the Spotify Web API.
///
/// It exists solely to override `refetch_token` so that a refresh grant which
/// omits `refresh_token` preserves the previously stored refresh token instead
/// of overwriting it with `None`. Spotify stopped rotating (and now expires)
/// refresh tokens, so the refresh response no longer echoes one back.
///
/// See:
/// - <https://developer.spotify.com/blog/2026-06-18-refresh-token-expiration>
/// - <https://github.com/aome510/spotify-player/issues/1040>
#[derive(Clone, Debug, Default)]
pub struct WebApiClient(AuthCodePkceSpotify);

impl WebApiClient {
    pub fn new(inner: AuthCodePkceSpotify) -> Self {
        Self(inner)
    }

    pub fn get_authorize_url(&mut self, verifier_bytes: Option<usize>) -> ClientResult<String> {
        self.0.get_authorize_url(verifier_bytes)
    }
}

#[maybe_async]
impl BaseClient for WebApiClient {
    fn get_http(&self) -> &HttpClient {
        self.0.get_http()
    }

    fn get_token(&self) -> Arc<Mutex<Option<Token>>> {
        self.0.get_token()
    }

    fn get_creds(&self) -> &Credentials {
        self.0.get_creds()
    }

    fn get_config(&self) -> &Config {
        self.0.get_config()
    }

    async fn refetch_token(&self) -> ClientResult<Option<Token>> {
        // Capture the current refresh token before refreshing
        let previous_refresh_token = self
            .0
            .get_token()
            .lock()
            .await
            .unwrap()
            .as_ref()
            .and_then(|token| token.refresh_token.clone());

        // Spotify's refresh response no longer includes `refresh_token`; carry the
        // previous one forward so it is not lost on the round-trip and nulled in the
        // token cache.
        let refreshed = self.0.refetch_token().await?;
        Ok(refreshed.map(|mut token| {
            if token.refresh_token.is_none() {
                token.refresh_token = previous_refresh_token;
            }
            token
        }))
    }
}

#[maybe_async]
impl OAuthClient for WebApiClient {
    fn get_oauth(&self) -> &OAuth {
        self.0.get_oauth()
    }

    async fn request_token(&self, code: &str) -> ClientResult<()> {
        self.0.request_token(code).await
    }
}