diff --git a/Cargo.lock b/Cargo.lock index f448519..36b0c49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,60 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -454,6 +508,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.6.0" @@ -466,6 +526,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -729,6 +790,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "maybe-async" version = "0.2.10" @@ -746,6 +813,12 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1202,6 +1275,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1257,6 +1340,7 @@ dependencies = [ name = "spotify-top-songs-playlist" version = "0.1.0" dependencies = [ + "axum", "dotenvy", "futures-util", "nanodb", @@ -1458,6 +1542,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1496,6 +1581,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] diff --git a/Cargo.toml b/Cargo.toml index 1866c44..a56f2a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] +axum = "0.8.4" dotenvy = "0.15.7" futures-util = "0.3.31" nanodb = "0.4.5" -rspotify = { version = "0.14.0", default-features = false, features = ["cli", "client-reqwest", "reqwest-rustls-tls", "webbrowser"] } +rspotify = { version = "0.14.0", default-features = false, features = ["cli", "client-reqwest", "reqwest-rustls-tls"] } serde = "1.0.219" tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } + diff --git a/html/callback.html b/html/callback.html new file mode 100644 index 0000000..f064ddf --- /dev/null +++ b/html/callback.html @@ -0,0 +1,19 @@ + + + + + Spotify Top Songs Playlist | Setup Complete + + + + + +
+

Spotify Top Songs Playlist

+

Setup Complete!

+

Look for a new playlist on Spotify

+
+ + + + diff --git a/html/error.html b/html/error.html new file mode 100644 index 0000000..5e84780 --- /dev/null +++ b/html/error.html @@ -0,0 +1,22 @@ + + + + + Spotify Top Songs Playlist + + + + + +
+

Spotify Top Songs Playlist

+

Error Occurred

+

{{ ERROR_MESSAGE }}

+ +

Return Home

+
+
+ + + + diff --git a/html/home.html b/html/home.html new file mode 100644 index 0000000..cd205a2 --- /dev/null +++ b/html/home.html @@ -0,0 +1,26 @@ + + + + + Spotify Top Songs Playlist + + + + + +
+

Spotify Top Songs Playlist

+

Create an auto-updating playlist containing your recent top played tracks

+ + Spotify Logo +

Connect Spotify

+
+
+ + + + diff --git a/html/style.css b/html/style.css new file mode 100644 index 0000000..96c4782 --- /dev/null +++ b/html/style.css @@ -0,0 +1,67 @@ +body { + background-color: #121212; + color: white; + font-family: sans-serif; +} + +div.center { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + flex-direction: column; +} + +h1 h2 { + margin: 5px; +} + +p.description { + color: grey; + margin: 15px; +} + +p.error { + color: red; +} + +a.btn { + color: white; + background-color: red; + border-radius: 10px; + padding: 5px; + margin: 5px; + text-decoration: none; + font-weight: bold; + display: flex; + align-items: center; + + p { + margin: 5px; + } +} + +a.btn:hover { + +} + +a.btn:active { + +} + +a.spotify { + background-color: #1ED760; +} + +a.spotify:hover { + background-color: #18ac4d; +} + +a.spotify:active { + background-color: #159643; +} + +img.logo-img { + margin: 5px; +} + diff --git a/src/main.rs b/src/main.rs index b1b5b9b..ee266ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,65 +5,305 @@ use rspotify::{ use futures_util::TryStreamExt; +use nanodb::nanodb::NanoDB; + +use serde::{Serialize, Deserialize}; + +use axum::{ + extract::Query, http::{header::CONTENT_TYPE, HeaderMap, StatusCode}, response::{Html, IntoResponse, Redirect}, routing::get, Router +}; + +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Deserialize)] +pub struct CallbackParams { + code: String, +} + +#[derive(Serialize, Deserialize)] +pub struct User { + id: String, + token: rspotify::Token, +} + +impl User { + pub fn get_client(self) -> AuthCodeSpotify { + AuthCodeSpotify::from_token(self.token) + } +} + +pub struct Config { + client_id: String, + client_secret: String, + redirect_uri: String, + playlist_name: String, + track_limit: usize, + database_path: String, + listen_on: String, +} + +impl Config { + pub fn from_env() -> Result { + let client_id = std::env::var("SPOTIFY_CLIENT_ID") + .map_err(|e| format!("SPOTIFY_CLIENT_ID must be set in .env file or as an environment variable: {e}"))?; + + let client_secret = std::env::var("SPOTIFY_CLIENT_SECRET") + .map_err(|e| format!("SPOTIFY_CLIENT_SECRET must be set in .env file or as an environment variable: {e}"))?; + + let redirect_uri = std::env::var("SPOTIFY_REDIRECT_URI") + .unwrap_or("http://127.0.0.1:3000/callback".to_string()); + + let playlist_name = std::env::var("PLAYLIST_NAME") + .unwrap_or("My Current Top Tracks".to_string()); + + let track_limit: usize = std::env::var("PLAYLIST_TRACK_LIMIT") + .unwrap_or("50".to_string()) + .parse() + .map_err(|e| format!("SPOTIFY_TRACK_LIMIT must be a valid number: {e}"))?; + + let database_path = std::env::var("DATABASE_FILE") + .unwrap_or("data.json".to_string()); + + let listen_on = std::env::var("LISTEN_ON") + .unwrap_or("0.0.0.0:3000".to_string()); + + Ok(Config { + client_id, + client_secret, + redirect_uri, + playlist_name, + track_limit, + database_path, + listen_on, + }) + } +} + #[tokio::main] -async fn main() -> Result<(), Box> { - dotenvy::dotenv()?; +async fn main() -> Result<(), String> { + println!("Starting Spotify Top Songs Playlist..."); - let client_id = std::env::var("SPOTIFY_CLIENT_ID") - .expect("SPOTIFY_CLIENT_ID must be set in .env file or as an environment variable"); + let _ = dotenvy::dotenv(); - let client_secret = std::env::var("SPOTIFY_CLIENT_SECRET") - .expect("SPOTIFY_CLIENT_SECRET must be set in .env file or as an environment variable"); + let config = Config::from_env() + .map_err(|e| format!("Config error: {e}"))?; - let redirect_uri = std::env::var("SPOTIFY_REDIRECT_URI") - .unwrap_or("http://127.0.0.1:8888/callback".to_string()); + let db = NanoDB::new_from(config.database_path.clone(), "{ \"users\": [] }") + .map_err(|e| format!("Error initializing database: {e}"))?; - let playlist_name = std::env::var("PLAYLIST_NAME") - .unwrap_or("My Current Top Tracks".to_string()); + let spotify_creds = Credentials::new(&config.client_id, &config.client_secret); - let track_limit: usize = std::env::var("PLAYLIST_TRACK_LIMIT") - .unwrap_or("25".to_string()) - .parse() - .expect("SPOTIFY_TRACK_LIMIT must be a valid number"); - - let credentials = Credentials::new(&client_id, &client_secret); - - let oauth = OAuth { - redirect_uri, + let spotify_oauth = OAuth { + redirect_uri: config.redirect_uri.clone(), scopes: scopes!("playlist-read-private playlist-modify-private user-top-read"), ..Default::default() }; - let spotify = AuthCodeSpotify::new(credentials, oauth); + // TODO gross (?) + let db = Arc::new(Mutex::new(db)); + let spotify_creds = Arc::new(spotify_creds); + let spotify_oauth = Arc::new(spotify_oauth); + let db2 = db.clone(); + let spotify_creds2 = spotify_creds.clone(); + let spotify_oauth2 = spotify_oauth.clone(); + let playlist_name2 = config.playlist_name.clone(); + let app = Router::new() + .route("/style.css", get(css)) + .route("/", get(|| async { Html(include_str!("../html/home.html")) })) + .route("/authorize", get(move || authorize((*spotify_creds).clone(), (*spotify_oauth).clone()))) + .route("/callback", get(move |callback_params: Query| + callback((*spotify_creds2).clone(), (*spotify_oauth2).clone(), db.clone(), callback_params, playlist_name2, config.track_limit))); + + let listener = tokio::net::TcpListener::bind(&config.listen_on).await + .map_err(|e| format!("Error listening: {e}"))?; + + println!("Listening on: {}", config.listen_on); + + tokio::spawn(async move { + println!("Starting playlist update loop..."); + update_loop(db2, config.playlist_name, config.track_limit).await + }); + + axum::serve(listener, app).await + .map_err(|e| format!("Error serving application: {e}"))?; + + Err("axum stopped listening".to_string()) +} + +async fn css() -> impl IntoResponse { + let mut headers = HeaderMap::with_capacity(1); + headers.insert(CONTENT_TYPE, "text/css".parse().unwrap()); + (StatusCode::OK, headers, include_str!("../html/style.css")) +} + +fn error(message: &str) -> Html { + let page = include_str!("../html/error.html").replace("{{ ERROR_MESSAGE }}", message); + Html(page) +} + +async fn authorize(spotify_creds: Credentials, spotify_oauth: OAuth) -> axum::response::Response { println!("Performing authorization..."); - let url = spotify.get_authorize_url(false)?; - spotify.prompt_for_token(&url).await?; - loop { - println!("Updating playlist..."); - top_songs_playlist(&spotify, &playlist_name, track_limit).await?; + let spotify = AuthCodeSpotify::new(spotify_creds, spotify_oauth); - // Wait 1 hour before updating again - tokio::time::sleep(std::time::Duration::from_secs(60 * 60)).await; + match spotify.get_authorize_url(false) { + Ok(url) => { + println!("Redirect: {url}"); + Redirect::to(&url).into_response() + }, + Err(e) => error(&format!("Authorization Error: {e}")).into_response() } } -async fn top_songs_playlist<'a>(spotify: &AuthCodeSpotify, playlist_name: &str, track_limit: usize) -> Result<(), ClientError> { +async fn callback(spotify_creds: Credentials, spotify_oauth: OAuth, db: Arc>, callback_params: Query, playlist_name: String, track_limit: usize) -> Html { + println!("Callback triggered"); + + let spotify = AuthCodeSpotify::new(spotify_creds, spotify_oauth); + + if let Err(e) = spotify.request_token(&callback_params.code).await { + return error(&format!("Error requesting Spotify token: {e}")) + } + + let user_id = match spotify.current_user().await { + Ok(user) => user.id.id().to_string(), + Err(e) => return error(&format!("Error fetching user from database: {e}")) + }; + + let token = match spotify.get_token().lock().await.map(|token| token.clone()) { + Ok(Some(token)) => token, + Ok(None) => { + return error("No token found") + }, + Err(_e) => { + return error("Error locking token") + } + }; + + let user = User { + id: user_id.to_string(), + token + }; + + { + let db_guard = db.lock().await; + let mut db = db_guard.update().await; + + let db_users = match db.get("users") { + Ok(users) => users, + Err(e) => { + return error(&format!("Error getting users from database: {e}")) + } + }; + + let users: Vec = match db_users.into() { + Ok(users) => users, + Err(e) => { + return error(&format!("Error deserializing users from database: {e}")) + } + }; + + for (i, user) in users.iter().enumerate() { + if user.id == user_id { + println!("user found!"); + if let Err(e) = db_users.remove_at(i) { + return error(&format!("Error removing existing user from database: {e}")) + } + } + } + + if let Err(e) = db_users.push(user) { + return error(&format!("Error adding user to database: {e}")) + } + } + + let mut db_guard = db.lock().await; + + if let Err(e) = db_guard.write().await { + return error(&format!("Error writing to database: {e}")) + } + + if let Err(e) = top_songs_playlist(&spotify, &playlist_name, track_limit).await { + return error(&format!("Error updating playlist: {e}")); + } + + Html(include_str!("../html/callback.html").to_string()) +} + + +async fn update_loop(db: Arc>, playlist_name: String, track_limit: usize) -> ! { + println!("Starting update loop..."); + + let loop_sleep = || { tokio::time::sleep(std::time::Duration::from_secs(60 * 30)) }; + let err_sleep = || { tokio::time::sleep(std::time::Duration::from_secs(30)) }; + + loop { + println!("Running update..."); + + let users: Vec = { + let db_guard = db.lock().await; + let mut db_read = db_guard.read().await; + + let users_field = match db_read.get("users") { + Ok(users) => users, + Err(e) => { + eprintln!("Error getting users: {e}"); + err_sleep().await; + continue; + } + }; + + match users_field.into() { + Ok(users) => users, + Err(e) => { + eprintln!("Error deserializing users: {e}"); + err_sleep().await; + continue; + } + } + }; + + let user_tasks = users.into_iter().map(|user| { + tokio::spawn(update_user(user, playlist_name.clone(), track_limit)) + }).collect::>(); + + for user_handle in user_tasks { + let _ = user_handle.await; + } + + println!("Update finished."); + + loop_sleep().await; + } +} + +async fn update_user(user: User, playlist_name: String, track_limit: usize) { + let spotify = user.get_client(); + + if let Err(e) = spotify.auto_reauth().await { + eprintln!("Error enabling auto-reauth: {e}"); + return; + } + + if let Err(e) = top_songs_playlist(&spotify, &playlist_name, track_limit).await { + eprintln!("Error updating playlist: {e}"); + } +} + + +async fn top_songs_playlist(spotify: &AuthCodeSpotify, playlist_name: &str, track_limit: usize) -> Result<(), ClientError> { let mut top_tracks = spotify.current_user_top_tracks(Some(TimeRange::ShortTerm)); let mut top_track_ids = Vec::new(); while let Some(top_track) = top_tracks.try_next().await? && top_track_ids.len() < track_limit { if let Some(track_id) = top_track.id { top_track_ids.push(track_id); - } else { - println!("Warning: Track ID not available (probably a local track), ignoring"); } } - println!("Adding top tracks to playlist: {}", playlist_name); - let playlist_id = find_or_create_playlist(&spotify, playlist_name, spotify.me().await?.id).await?; - spotify.playlist_replace_items(playlist_id, top_track_ids.into_iter().map(|track| PlayableId::Track(track))).await?; + let playlist_id = find_or_create_playlist(spotify, playlist_name, spotify.me().await?.id).await?; + spotify.playlist_replace_items(playlist_id, top_track_ids.into_iter().map(PlayableId::Track)).await?; Ok(()) } @@ -82,3 +322,4 @@ async fn find_or_create_playlist<'a>(spotify: &AuthCodeSpotify, Ok(playlist.id) } +