Finish project

This commit is contained in:
2025-08-01 12:37:53 -04:00
parent 260142445a
commit fde8f3d7a3
7 changed files with 497 additions and 34 deletions

86
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"] }

19
html/callback.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/style.css">
<title>Spotify Top Songs Playlist | Setup Complete</title>
<link rel="icon" type="image/x-icon" href="https://spotify.com/favicon.ico">
</head>
<body>
<div class="center">
<h1>Spotify Top Songs Playlist</h1>
<h2>Setup Complete!</h1>
<p class="description">Look for a new playlist on Spotify</p>
</div>
</body>
</html>

22
html/error.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/style.css">
<title>Spotify Top Songs Playlist</title>
<link rel="icon" type="image/x-icon" href="https://spotify.com/favicon.ico">
</head>
<body>
<div class="center">
<h1>Spotify Top Songs Playlist</h1>
<h2>Error Occurred</h2>
<p class="description error">{{ ERROR_MESSAGE }}</p>
<a href="/" class="btn home">
<p>Return Home</p>
</a>
</div>
</body>
</html>

26
html/home.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/style.css">
<title>Spotify Top Songs Playlist</title>
<link rel="icon" type="image/x-icon" href="https://spotify.com/favicon.ico">
</head>
<body>
<div class="center">
<h1>Spotify Top Songs Playlist</h1>
<p class="description">Create an auto-updating playlist containing your recent top played tracks</p>
<a href="/authorize" class="btn spotify">
<img class="logo-img"
src="https://storage.googleapis.com/pr-newsroom-wp/1/2021/02/Spotify_Icon_RGB_White.png"
alt="Spotify Logo"
height="25"
/>
<p>Connect Spotify</p>
</a>
</div>
</body>
</html>

67
html/style.css Normal file
View File

@ -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;
}

View File

@ -5,65 +5,305 @@ use rspotify::{
use futures_util::TryStreamExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv()?;
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<Self, String> {
let client_id = std::env::var("SPOTIFY_CLIENT_ID")
.expect("SPOTIFY_CLIENT_ID must be set in .env file or as an environment variable");
.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")
.expect("SPOTIFY_CLIENT_SECRET must be set in .env file or as an environment variable");
.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:8888/callback".to_string());
.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("25".to_string())
.unwrap_or("50".to_string())
.parse()
.expect("SPOTIFY_TRACK_LIMIT must be a valid number");
.map_err(|e| format!("SPOTIFY_TRACK_LIMIT must be a valid number: {e}"))?;
let credentials = Credentials::new(&client_id, &client_secret);
let database_path = std::env::var("DATABASE_FILE")
.unwrap_or("data.json".to_string());
let oauth = OAuth {
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<(), String> {
println!("Starting Spotify Top Songs Playlist...");
let _ = dotenvy::dotenv();
let config = Config::from_env()
.map_err(|e| format!("Config error: {e}"))?;
let db = NanoDB::new_from(config.database_path.clone(), "{ \"users\": [] }")
.map_err(|e| format!("Error initializing database: {e}"))?;
let spotify_creds = Credentials::new(&config.client_id, &config.client_secret);
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<CallbackParams>|
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<String> {
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<Mutex<NanoDB>>, callback_params: Query<CallbackParams>, playlist_name: String, track_limit: usize) -> Html<String> {
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<User> = 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<Mutex<NanoDB>>, 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<User> = {
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::<Vec<_>>();
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)
}