wip spotify api setup

This commit is contained in:
Daniel Bulant 2024-10-14 16:42:46 +02:00
parent a8800d9a68
commit a3d7ed83d7
No known key found for this signature in database
10 changed files with 2015 additions and 66 deletions

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
target
result
http-cacache
http-cacache
refresh_token.txt

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"nixEnvSelector.nixFile": "${workspaceFolder}/flake.nix"
}

1678
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "rshell"
description = "Another GUI shell"
name = "despot"
description = "Another Spotify client"
version = "0.1.0"
edition = "2021"
@ -19,3 +19,11 @@ hsl = "0.1.1"
itertools = "0.10.0"
palette = "0.7.3"
clap = { version = "4.5.20", features = ["derive"] }
chrono = "0.4"
librespot-core = { git = "https://github.com/photovoltex/librespot.git", branch = "integrate-dealer" }
librespot-oauth = { git = "https://github.com/photovoltex/librespot.git", branch = "integrate-dealer" }
librespot-playback = { git = "https://github.com/photovoltex/librespot.git", branch = "integrate-dealer" }
librespot-protocol = { git = "https://github.com/photovoltex/librespot.git", branch = "integrate-dealer" }
futures-util = { version = "0.3", features = ["alloc", "bilock", "sink", "unstable"] }
rspotify = { version = "0.13.3" }
oauth2 = "4.4"

95
src/api.rs Normal file
View file

@ -0,0 +1,95 @@
use std::future::Future;
use std::time::Duration;
use chrono::TimeDelta;
use librespot_core::Session;
use librespot_oauth::OAuthToken;
use reqwest::StatusCode;
use rspotify::{AuthCodeSpotify, ClientError, ClientResult, Config, Token};
use rspotify::http::HttpError;
use crate::auth::{rspotify_scopes, SPOTIFY_REDIRECT_URI};
struct SpotifyContext {
session: Session,
api: AuthCodeSpotify,
token: OAuthToken
}
impl SpotifyContext {
fn new(session: Session, token: OAuthToken) -> SpotifyContext {
let config = Config {
token_refreshing: false,
..Default::default()
};
let api = AuthCodeSpotify::from_token_with_config(
librespot_token_to_rspotify(&token),
rspotify::Credentials::default(),
rspotify::OAuth {
proxies: None,
redirect_uri: SPOTIFY_REDIRECT_URI.to_string(),
scopes: rspotify_scopes(),
state: String::new()
},
config,
);
SpotifyContext { session, api, token }
}
/// Execute `api_call` and retry once if a rate limit occurs.
async fn api_with_retry<F, T: Future<Output = ClientResult<R>>, R>(&self, api_call: F) -> Option<R>
where
F: Fn(&AuthCodeSpotify) -> T,
{
let result = { api_call(&self.api).await };
match result {
Ok(v) => Some(v),
Err(ClientError::Http(error)) => {
dbg!("http error: {:?}", &error);
if let HttpError::StatusCode(response) = error.as_ref() {
match response.status() {
StatusCode::TOO_MANY_REQUESTS => {
let waiting_duration = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok().and_then(|v| v.parse::<u64>().ok()));
dbg!("rate limit hit. waiting {:?} seconds", waiting_duration);
// sleep with tokio instead
tokio::time::sleep(Duration::from_secs(waiting_duration.unwrap_or(1))).await;
api_call(&self.api).await.ok()
}
StatusCode::UNAUTHORIZED => {
dbg!("token unauthorized. trying refresh..");
// self.update_token()
// .and_then(move |_| api_call(&self.api).await.ok())
None
}
_ => {
eprintln!("unhandled api error: {:?}", response);
None
}
}
} else {
None
}
}
Err(e) => {
eprintln!("unhandled api error: {}", e);
None
}
}
}
}
fn librespot_token_to_rspotify(token: &OAuthToken) -> Token {
Token {
access_token: token.access_token.clone(),
scopes: rspotify_scopes(),
refresh_token: None,
expires_at: None,
expires_in: TimeDelta::zero()
}
}

125
src/auth.rs Normal file
View file

@ -0,0 +1,125 @@
use std::{collections::HashSet, io::{Read, Write}, time::{Duration, Instant}};
use librespot_core::SessionConfig;
use librespot_oauth::{get_access_token, OAuthError, OAuthToken};
use oauth2::{basic::BasicClient, reqwest::http_client, AuthUrl, ClientId, RedirectUrl, RefreshToken, TokenResponse, TokenUrl};
pub const SPOTIFY_REDIRECT_URI: &str = "http://127.0.0.1:8898/login";
const SPOTIFY_SCOPES: [&str; 16] = [
"user-read-playback-state",
"user-modify-playback-state",
"user-read-currently-playing",
"app-remote-control",
"streaming",
"playlist-read-private",
"playlist-read-collaborative",
"playlist-modify-private",
"playlist-modify-public",
"user-follow-modify",
"user-follow-read",
"user-read-playback-position",
"user-top-read",
"user-read-recently-played",
"user-library-modify",
"user-library-read",
];
pub fn rspotify_scopes() -> HashSet<String> {
HashSet::from_iter(SPOTIFY_SCOPES.map(|t| t.to_string()))
}
fn get_refresh_token_file_location() -> String {
"./refresh_token.txt".to_string()
}
fn read_refresh_token() -> Option<String> {
let file = std::fs::File::open(get_refresh_token_file_location());
if file.is_err() {
return None;
}
let mut reader = std::io::BufReader::new(file.unwrap());
let mut token = String::new();
reader.read_to_string(&mut token).unwrap();
Some(token)
}
fn write_refresh_token(token: &str) {
let mut file = std::fs::File::create(get_refresh_token_file_location()).unwrap();
file.write_all(token.as_bytes()).unwrap();
}
fn oauth2_client() -> Result<BasicClient, OAuthError> {
let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string())
.map_err(|_| OAuthError::InvalidSpotifyUri)?;
let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string())
.map_err(|_| OAuthError::InvalidSpotifyUri)?;
let redirect_url =
RedirectUrl::new(SPOTIFY_REDIRECT_URI.to_string()).map_err(|e| OAuthError::InvalidRedirectUri {
uri: SPOTIFY_REDIRECT_URI.to_string(),
e,
})?;
let client = BasicClient::new(
ClientId::new(SessionConfig::default().client_id),
None,
auth_url,
Some(token_url),
);
let client = client.set_redirect_uri(redirect_url);
Ok(client)
}
fn get_access_token_from_refresh_token(refresh_token: &str) -> Result<OAuthToken, OAuthError> {
let client = oauth2_client()?;
let token = client
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
.request(http_client)
.map_err(|e| { dbg!(e); OAuthError::ExchangeCode { e: refresh_token.to_string() } })?;
let refresh_token = match token.refresh_token() {
Some(t) => t.secret().to_string(),
_ => "".to_string(), // Spotify always provides a refresh token.
};
write_refresh_token(&refresh_token);
Ok(OAuthToken {
access_token: token.access_token().secret().to_string(),
refresh_token,
expires_at: Instant::now()
+ token
.expires_in()
.unwrap_or_else(|| Duration::from_secs(3600)),
token_type: format!("{:?}", token.token_type()).to_string(), // Urgh!?
scopes: Vec::from(SPOTIFY_SCOPES.map(|s| s.to_string())),
})
}
pub fn get_token() -> Result<OAuthToken, OAuthError> {
let refresh = read_refresh_token();
match refresh {
Some(token) => {
let token = get_access_token_from_refresh_token(&token);
match token {
Ok(token) => return Ok(token),
Err(e) => {
eprintln!("Error refreshing token, trying to relogin. Error: {}", e);
}
}
}
None => {}
};
let token = match get_access_token(&SessionConfig::default().client_id, SPOTIFY_REDIRECT_URI, Vec::from(SPOTIFY_SCOPES)) {
Ok(token) => token,
Err(e) => {
eprintln!("Error: {}", e);
return Err(e);
}
};
write_refresh_token(&token.refresh_token);
Ok(token)
}

View file

@ -1,14 +1,61 @@
use std::thread;
use auth::get_token;
use clap::Parser;
use cli::Args;
use cushy::{PendingApp, Run, TokioRuntime};
use librespot_core::{authentication::Credentials, Session, SessionConfig};
use librespot_playback::{audio_backend, config::{AudioFormat, PlayerConfig}, mixer::NoOpVolume, player::Player};
mod vibrancy;
mod theme;
mod cli;
mod auth;
mod widgets;
mod rt;
mod api;
fn main() -> cushy::Result {
let args = Args::parse();
let mut app = PendingApp::new(TokioRuntime::default());
let token = get_token().unwrap();
let session_config = SessionConfig::default();
let player_config = PlayerConfig::default();
let audio_format = AudioFormat::default();
let credentials = Credentials::with_access_token(&token.access_token);
let backend = audio_backend::find(None).unwrap();
let session;
{
let guard = app.cushy().enter_runtime();
session = Session::new(session_config, None);
let player = Player::new(player_config, session.clone(), Box::new(NoOpVolume), move || {
backend(None, audio_format)
});
tokio::spawn({ let session = session.clone(); async move {
if let Err(e) = session.connect(credentials, false).await {
println!("Error connecting: {}", e);
}
}});
thread::spawn(move || {
let mut channel = player.get_player_event_channel();
loop {
let event = channel.blocking_recv();
if let Some(event) = event {
dbg!(event);
} else { break; }
}
});
drop(guard);
}
dbg!(session.user_data());
app.run()
}

25
src/rt.rs Normal file
View file

@ -0,0 +1,25 @@
use tokio::runtime;
pub(crate) fn tokio_runtime() -> &'static runtime::Handle {
use std::sync::OnceLock;
use std::time::Duration;
static RUNTIME: OnceLock<runtime::Handle> = OnceLock::new();
RUNTIME.get_or_init(|| {
let rt = runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio initialization error");
let handle = rt.handle().clone();
std::thread::spawn(move || {
// Replace with the async main loop, or some sync structure to
// control shutting it down if desired.
rt.block_on(async {
loop {
tokio::time::sleep(Duration::from_secs(10000)).await
}
});
});
handle
})
}

92
src/widgets/image.rs Normal file
View file

@ -0,0 +1,92 @@
use std::sync::Arc;
use cushy::{kludgine::{AnyTexture, LazyTexture}, value::{CallbackHandle, Destination, Dynamic, Source, Value}, widgets::Image};
use futures_util::lock::Mutex;
use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions};
use image::imageops::FilterType;
use reqwest::Client;
use reqwest_middleware::ClientBuilder;
use tokio::task::JoinHandle;
use crate::rt::tokio_runtime;
trait ImageExt {
fn new_empty() -> Self;
fn load_url(&mut self, url: Dynamic<Option<String>>) -> CallbackHandle;
}
impl ImageExt for Image {
fn new_empty() -> Self {
Image::new(Dynamic::new(get_empty_texture()))
}
fn load_url(&mut self, url: Dynamic<Option<String>>) -> CallbackHandle {
let client = ClientBuilder::new(Client::new())
.with(Cache(HttpCache {
mode: CacheMode::Default,
manager: CACacheManager::default(),
options: HttpCacheOptions::default(),
}))
.build();
// let texture = Dynamic::new(get_empty_texture());
match &mut self.contents {
Value::Constant(_) => self.contents = Value::Dynamic(Dynamic::new(get_empty_texture())),
Value::Dynamic(dynamic) => dynamic.set(get_empty_texture()),
}
let texture = match &self.contents {
Value::Dynamic(dynamic) => dynamic,
_ => unreachable!()
};
let texture = texture.clone();
let prev_request_join = Arc::new(Mutex::new(None::<JoinHandle<()>>));
url.for_each({
let texture = texture.clone();
move |url| {
let guard = tokio_runtime().enter();
let url = url.clone();
let prev_request_join = prev_request_join.clone();
let texture = texture.clone();
let client = client.clone();
tokio::spawn(async move {
let mut prev_request_join = prev_request_join.lock().await;
if let Some(prev_request_join) = prev_request_join.take() {
prev_request_join.abort();
}
if let Some(url) = url {
let texture = texture.clone();
let client = client.clone();
*prev_request_join = Some(tokio::spawn(async move {
let response = client.get(url).send().await.unwrap();
let bytes = response.bytes().await.unwrap();
let image = image::load_from_memory(&bytes).unwrap();
let image = image.resize(128, 128, FilterType::Lanczos3);
let image_texture = LazyTexture::from_image(image, cushy::kludgine::wgpu::FilterMode::Linear);
let image_texture = AnyTexture::Lazy(image_texture);
texture.set(image_texture);
}));
} else {
texture.set(get_empty_texture());
}
});
drop(guard);
}
})
}
}
fn get_empty_texture() -> AnyTexture {
AnyTexture::Lazy(
LazyTexture::from_image(
image::DynamicImage::ImageRgba8(
image::ImageBuffer::new(1, 1)
),
cushy::kludgine::wgpu::FilterMode::Linear
)
)
}

1
src/widgets/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod image;