use std::fs;
use std::net::TcpListener;
use std::path::Path;
use librespot_core::authentication::Credentials as RespotCredentials;
use librespot_core::cache::Cache;
use librespot_oauth::OAuthClientBuilder;
use log::{error, info, warn};
use crate::config::{self, Config};
use crate::spotify::Spotify;
pub const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
pub const NCSPOT_CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b";
static OAUTH_SCOPES: &[&str] = &[
"playlist-modify",
"playlist-modify-private",
"playlist-modify-public",
"playlist-read",
"playlist-read-collaborative",
"playlist-read-private",
"streaming",
"user-follow-modify",
"user-follow-read",
"user-library-modify",
"user-library-read",
"user-modify",
"user-modify-playback-state",
"user-modify-private",
"user-personalized",
"user-read-currently-playing",
"user-read-email",
"user-read-play-history",
"user-read-playback-position",
"user-read-playback-state",
"user-read-private",
"user-read-recently-played",
"user-top-read",
];
static NCSPOT_OAUTH_SCOPES: &[&str] = &[
"streaming",
"user-read-email",
"user-read-private",
"user-library-read",
"user-library-modify",
"user-read-playback-state",
"user-modify-playback-state",
"playlist-read-private",
"playlist-modify-public",
"playlist-modify-private",
"user-follow-read",
"user-follow-modify",
"user-top-read",
"user-read-currently-playing",
"user-read-recently-played",
];
pub fn find_free_port() -> Result<u16, String> {
let socket = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
socket
.local_addr()
.map(|addr| addr.port())
.map_err(|e| e.to_string())
}
pub fn get_client_redirect_uri() -> String {
let auth_port = find_free_port().expect("Could not find free port");
let redirect_url = format!("http://127.0.0.1:{auth_port}/login");
redirect_url
}
pub fn get_credentials(configuration: &Config) -> Result<RespotCredentials, String> {
let mut credentials = {
let cache = Cache::new(Some(config::cache_path("librespot")), None, None, None)
.expect("Could not create librespot cache");
let cached_credentials = cache.credentials();
match cached_credentials {
Some(c) => {
info!("Using cached credentials");
c
}
None => {
info!("Attempting to login via OAuth2");
credentials_prompt(None)?
}
}
};
while let Err(error) = Spotify::test_credentials(configuration, credentials.clone()) {
let error_msg = format!("{error}");
credentials = credentials_prompt(Some(error_msg))?;
}
Ok(credentials)
}
fn credentials_prompt(error_message: Option<String>) -> Result<RespotCredentials, String> {
if let Some(message) = error_message {
eprintln!("Connection error: {message}");
}
create_credentials()
}
pub fn create_credentials() -> Result<RespotCredentials, String> {
println!("To login you need to perform OAuth2 authorization using your web browser\n");
let client_builder = OAuthClientBuilder::new(
SPOTIFY_CLIENT_ID,
&get_client_redirect_uri(),
OAUTH_SCOPES.to_vec(),
);
let oauth_client = client_builder.build().map_err(|e| e.to_string())?;
oauth_client
.get_access_token()
.map(|token| RespotCredentials::with_access_token(token.access_token))
.map_err(|e| e.to_string())
}
pub fn get_rspotify_token() -> Result<rspotify::Token, String> {
let path = config::cache_path("rspotify_token.json");
let token = if let Ok(token_json) = fs::read_to_string(&path) {
serde_json::from_str::<rspotify::Token>(&token_json).ok()
} else {
None
};
if let Some(t) = token {
if !t.is_expired() {
return Ok(t);
}
let refresh_token = t.refresh_token.as_deref().filter(|s| !s.is_empty());
if let Some(refresh_token) = refresh_token {
info!("Access token expired, attempting to refresh..");
let client_builder = OAuthClientBuilder::new(
NCSPOT_CLIENT_ID,
&get_client_redirect_uri(),
NCSPOT_OAUTH_SCOPES.to_vec(),
);
if let Ok(oauth_client) = client_builder.build() {
match oauth_client.refresh_token(refresh_token) {
Ok(new_token) => {
let mapped = map_token(new_token, Some(refresh_token));
write_token(&path, &mapped);
return Ok(mapped);
}
Err(e) => {
error!("Failed to refresh token: {e}");
}
}
}
}
}
let t = create_rspotify_token()?;
write_token(&path, &t);
Ok(t)
}
pub fn create_rspotify_token() -> Result<rspotify::Token, String> {
println!(
"To fully enable Web API features, you need to perform a second OAuth2 authorization\n"
);
let client_builder = OAuthClientBuilder::new(
NCSPOT_CLIENT_ID,
&get_client_redirect_uri(),
NCSPOT_OAUTH_SCOPES.to_vec(),
);
let oauth_client = client_builder.build().map_err(|e| e.to_string())?;
oauth_client
.get_access_token()
.map(|token| map_token(token, None))
.map_err(|e| e.to_string())
}
fn write_token(path: &Path, token: &rspotify::Token) {
let json = match serde_json::to_string_pretty(token) {
Ok(json) => json,
Err(e) => {
error!("Failed to serialize rspotify token: {e}");
return;
}
};
if let Err(e) = fs::write(path, json) {
error!("Failed to write rspotify token cache: {e}");
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
warn!("Failed to restrict permissions on rspotify token cache: {e}");
}
}
}
fn map_token(
token: librespot_oauth::OAuthToken,
fallback_refresh_token: Option<&str>,
) -> rspotify::Token {
let duration = if token.expires_at > std::time::Instant::now() {
token.expires_at.duration_since(std::time::Instant::now())
} else {
std::time::Duration::from_secs(0)
};
let expires_in = chrono::Duration::from_std(duration).unwrap_or(chrono::Duration::seconds(0));
let refresh_token = if token.refresh_token.is_empty() {
fallback_refresh_token.map(str::to_string)
} else {
Some(token.refresh_token)
};
rspotify::Token {
access_token: token.access_token,
expires_in,
scopes: std::collections::HashSet::new(),
expires_at: Some(chrono::Utc::now() + expires_in),
refresh_token,
}
}
#[cfg(test)]
mod test {
use super::*;
fn oauth_token(refresh_token: &str) -> librespot_oauth::OAuthToken {
librespot_oauth::OAuthToken {
access_token: "access".to_string(),
refresh_token: refresh_token.to_string(),
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(3600),
token_type: "Bearer".to_string(),
scopes: Vec::new(),
}
}
#[test]
fn map_token_keeps_new_refresh_token_when_present() {
let mapped = map_token(oauth_token("new-refresh-token"), Some("old-refresh-token"));
assert_eq!(mapped.refresh_token, Some("new-refresh-token".to_string()));
}
#[test]
fn map_token_falls_back_to_previous_refresh_token_when_omitted() {
let mapped = map_token(oauth_token(""), Some("old-refresh-token"));
assert_eq!(mapped.refresh_token, Some("old-refresh-token".to_string()));
}
#[test]
fn map_token_yields_no_refresh_token_when_omitted_without_fallback() {
let mapped = map_token(oauth_token(""), None);
assert_eq!(mapped.refresh_token, None);
}
}