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
+
+
+ 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)
}
+