mirror of
https://github.com/danbulant/despot
synced 2026-05-19 03:58:32 +00:00
wip spotify api setup
This commit is contained in:
parent
a8800d9a68
commit
a3d7ed83d7
10 changed files with 2015 additions and 66 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
target
|
||||
result
|
||||
http-cacache
|
||||
http-cacache
|
||||
refresh_token.txt
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"nixEnvSelector.nixFile": "${workspaceFolder}/flake.nix"
|
||||
}
|
||||
1678
Cargo.lock
generated
1678
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
12
Cargo.toml
12
Cargo.toml
|
|
@ -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
95
src/api.rs
Normal 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
125
src/auth.rs
Normal 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)
|
||||
}
|
||||
47
src/main.rs
47
src/main.rs
|
|
@ -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
25
src/rt.rs
Normal 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
92
src/widgets/image.rs
Normal 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
1
src/widgets/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod image;
|
||||
Loading…
Reference in a new issue