mirror of
https://github.com/danbulant/despot
synced 2026-07-05 11:00:53 +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
|
target
|
||||||
result
|
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]
|
[package]
|
||||||
name = "rshell"
|
name = "despot"
|
||||||
description = "Another GUI shell"
|
description = "Another Spotify client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|
@ -19,3 +19,11 @@ hsl = "0.1.1"
|
||||||
itertools = "0.10.0"
|
itertools = "0.10.0"
|
||||||
palette = "0.7.3"
|
palette = "0.7.3"
|
||||||
clap = { version = "4.5.20", features = ["derive"] }
|
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 clap::Parser;
|
||||||
use cli::Args;
|
use cli::Args;
|
||||||
use cushy::{PendingApp, Run, TokioRuntime};
|
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 vibrancy;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod cli;
|
mod cli;
|
||||||
|
mod auth;
|
||||||
|
mod widgets;
|
||||||
|
mod rt;
|
||||||
|
mod api;
|
||||||
|
|
||||||
fn main() -> cushy::Result {
|
fn main() -> cushy::Result {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
let mut app = PendingApp::new(TokioRuntime::default());
|
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()
|
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