A decentralized music tracking and discovery platform built on AT Protocol 🎵

[webscrobbler] finish webscrobbler webhook implementation

+1728 -10
+4 -1
Cargo.lock
··· 5677 version = "0.1.0" 5678 dependencies = [ 5679 "actix-web", 5680 "anyhow", 5681 "chrono", 5682 "dotenv", 5683 "hex", 5684 "jsonwebtoken", 5685 "md5", 5686 "owo-colors", 5687 - "quick-xml 0.37.4", 5688 "redis", 5689 "reqwest", 5690 "serde", 5691 "serde_json", 5692 "sqlx", 5693 "tokio", 5694 ] 5695 5696 [[package]]
··· 5677 version = "0.1.0" 5678 dependencies = [ 5679 "actix-web", 5680 + "aes", 5681 "anyhow", 5682 "chrono", 5683 + "ctr", 5684 "dotenv", 5685 "hex", 5686 "jsonwebtoken", 5687 "md5", 5688 "owo-colors", 5689 + "rand 0.9.0", 5690 "redis", 5691 "reqwest", 5692 "serde", 5693 "serde_json", 5694 "sqlx", 5695 "tokio", 5696 + "tokio-stream", 5697 ] 5698 5699 [[package]]
+4 -1
crates/webscrobbler/Cargo.toml
··· 35 "json", 36 "multipart", 37 ], default-features = false } 38 - quick-xml = { version = "0.37.4", features = ["serialize"] } 39 chrono = { version = "= 0.4.39", features = ["serde"] }
··· 35 "json", 36 "multipart", 37 ], default-features = false } 38 chrono = { version = "= 0.4.39", features = ["serde"] } 39 + aes = "0.8.4" 40 + ctr = "0.9.2" 41 + rand = "0.9.0" 42 + tokio-stream = { version = "0.1.17", features = ["full"] }
+63
crates/webscrobbler/src/auth.rs
···
··· 1 + use anyhow::Error; 2 + use jsonwebtoken::DecodingKey; 3 + use jsonwebtoken::EncodingKey; 4 + use jsonwebtoken::Header; 5 + use jsonwebtoken::Validation; 6 + use serde::{Deserialize, Serialize}; 7 + use std::env; 8 + 9 + #[derive(Debug, Serialize, Deserialize)] 10 + pub struct Claims { 11 + exp: usize, 12 + iat: usize, 13 + did: String, 14 + } 15 + 16 + pub fn generate_token(did: &str) -> Result<String, Error> { 17 + if env::var("JWT_SECRET").is_err() { 18 + return Err(Error::msg("JWT_SECRET is not set")); 19 + } 20 + 21 + let claims = Claims { 22 + exp: chrono::Utc::now().timestamp() as usize + 3600, 23 + iat: chrono::Utc::now().timestamp() as usize, 24 + did: did.to_string(), 25 + }; 26 + 27 + jsonwebtoken::encode( 28 + &Header::default(), 29 + &claims, 30 + &EncodingKey::from_secret(env::var("JWT_SECRET")?.as_ref()), 31 + ) 32 + .map_err(Into::into) 33 + } 34 + 35 + pub fn decode_token(token: &str) -> Result<Claims, Error> { 36 + if env::var("JWT_SECRET").is_err() { 37 + return Err(Error::msg("JWT_SECRET is not set")); 38 + } 39 + 40 + jsonwebtoken::decode::<Claims>( 41 + token, 42 + &DecodingKey::from_secret(env::var("JWT_SECRET")?.as_ref()), 43 + &Validation::default(), 44 + ) 45 + .map(|data| data.claims) 46 + .map_err(Into::into) 47 + } 48 + 49 + #[cfg(test)] 50 + mod tests { 51 + use dotenv::dotenv; 52 + 53 + use super::*; 54 + 55 + #[test] 56 + fn test_generate_token() { 57 + dotenv().ok(); 58 + let token = generate_token("did:plc:7vdlgi2bflelz7mmuxoqjfcr").unwrap(); 59 + let claims = decode_token(&token).unwrap(); 60 + 61 + assert_eq!(claims.did, "did:plc:7vdlgi2bflelz7mmuxoqjfcr"); 62 + } 63 + }
+22
crates/webscrobbler/src/crypto.rs
···
··· 1 + use std::env; 2 + 3 + use aes::{ 4 + cipher::{KeyIvInit, StreamCipher}, 5 + Aes256, 6 + }; 7 + use anyhow::Error; 8 + use hex::decode; 9 + 10 + type Aes256Ctr = ctr::Ctr64BE<Aes256>; 11 + 12 + pub fn decrypt_aes_256_ctr(encrypted_text: &str, key: &[u8]) -> Result<String, Error> { 13 + let iv = decode(env::var("SPOTIFY_ENCRYPTION_IV")?)?; 14 + let ciphertext = decode(encrypted_text)?; 15 + 16 + let mut cipher = 17 + Aes256Ctr::new_from_slices(key, &iv).map_err(|_| Error::msg("Invalid key or IV"))?; 18 + let mut decrypted_data = ciphertext.clone(); 19 + cipher.apply_keystream(&mut decrypted_data); 20 + 21 + Ok(String::from_utf8(decrypted_data)?) 22 + }
+55 -4
crates/webscrobbler/src/handlers.rs
··· 1 - use actix_web::{get, HttpResponse, Responder}; 2 3 - use crate::BANNER; 4 - 5 6 #[get("/")] 7 pub async fn index() -> impl Responder { 8 HttpResponse::Ok().body(BANNER) 9 - }
··· 1 + use std::sync::Arc; 2 + use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder}; 3 + use owo_colors::OwoColorize; 4 + use sqlx::{Pool, Postgres}; 5 + use crate::{cache::Cache, repo, scrobbler::scrobble, types::ScrobbleRequest, BANNER}; 6 + use tokio_stream::StreamExt; 7 8 + #[macro_export] 9 + macro_rules! read_payload { 10 + ($payload:expr) => {{ 11 + let mut body = Vec::new(); 12 + while let Some(chunk) = $payload.next().await { 13 + match chunk { 14 + Ok(bytes) => body.extend_from_slice(&bytes), 15 + Err(err) => return Err(err.into()), 16 + } 17 + } 18 + body 19 + }}; 20 + } 21 22 #[get("/")] 23 pub async fn index() -> impl Responder { 24 HttpResponse::Ok().body(BANNER) 25 + } 26 + 27 + #[post("/{id}")] 28 + async fn handle_scrobble( 29 + data: web::Data<Arc<Pool<Postgres>>>, 30 + cache: web::Data<Cache>, 31 + mut payload: web::Payload, 32 + req: HttpRequest, 33 + ) -> Result<impl Responder, actix_web::Error> { 34 + let id = req.match_info().get("id").unwrap(); 35 + println!("Received scrobble for ID: {}", id.cyan()); 36 + 37 + let pool = data.get_ref().clone(); 38 + 39 + let user = repo::user::get_user_by_webscrobbler(&pool, id).await 40 + .map_err(|err| actix_web::error::ErrorInternalServerError(format!("Database error: {}", err)))?; 41 + 42 + if user.is_none() { 43 + return Ok(HttpResponse::NotFound().body("There is no user with this webscrobbler ID")); 44 + } 45 + let user = user.unwrap(); 46 + 47 + let body = read_payload!(payload); 48 + let params = serde_json::from_slice::<ScrobbleRequest>(&body) 49 + .map_err(|err| actix_web::error::ErrorBadRequest(format!("Failed to parse JSON: {}", err)))?; 50 + 51 + println!("Parsed scrobble request: {:#?}", params); 52 + 53 + let cache = cache.get_ref().clone(); 54 + 55 + scrobble(&pool, &cache, params, &user.xata_id).await 56 + .map_err(|err| actix_web::error::ErrorInternalServerError(format!("Failed to scrobble: {}", err)))?; 57 + 58 + 59 + Ok(HttpResponse::Ok().body("Scrobble received")) 60 + }
+11 -4
crates/webscrobbler/src/main.rs
··· 7 use owo_colors::OwoColorize; 8 use sqlx::postgres::PgPoolOptions; 9 10 pub mod cache; 11 pub mod handlers; 12 13 pub const BANNER: &str = r#" 14 _ __ __ _____ __ __ __ ··· 29 30 let cache = Cache::new()?; 31 32 - 33 - 34 let pool = PgPoolOptions::new() 35 .max_connections(5) 36 .connect(&env::var("XATA_POSTGRES_URL")?) 37 .await?; 38 39 let conn = Arc::new(pool); 40 - 41 42 let host = env::var("WEBSCROBBLER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 43 let port = env::var("WEBSCROBBLER_PORT") ··· 46 .unwrap_or(7883); 47 48 println!( 49 - "Starting WebScrobbler WebHook @ {}", 50 format!("{}:{}", host, port).green() 51 ); 52 ··· 55 .app_data(Data::new(conn.clone())) 56 .app_data(Data::new(cache.clone())) 57 .service(handlers::index) 58 }) 59 .bind((host, port))? 60 .run()
··· 7 use owo_colors::OwoColorize; 8 use sqlx::postgres::PgPoolOptions; 9 10 + pub mod rocksky; 11 pub mod cache; 12 pub mod handlers; 13 + pub mod xata; 14 + pub mod types; 15 + pub mod repo; 16 + pub mod auth; 17 + pub mod spotify; 18 + pub mod musicbrainz; 19 + pub mod scrobbler; 20 + pub mod crypto; 21 22 pub const BANNER: &str = r#" 23 _ __ __ _____ __ __ __ ··· 38 39 let cache = Cache::new()?; 40 41 let pool = PgPoolOptions::new() 42 .max_connections(5) 43 .connect(&env::var("XATA_POSTGRES_URL")?) 44 .await?; 45 46 let conn = Arc::new(pool); 47 48 let host = env::var("WEBSCROBBLER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 49 let port = env::var("WEBSCROBBLER_PORT") ··· 52 .unwrap_or(7883); 53 54 println!( 55 + "Starting WebScrobbler Webhook @ {}", 56 format!("{}:{}", host, port).green() 57 ); 58 ··· 61 .app_data(Data::new(conn.clone())) 62 .app_data(Data::new(cache.clone())) 63 .service(handlers::index) 64 + .service(handlers::handle_scrobble) 65 }) 66 .bind((host, port))? 67 .run()
+86
crates/webscrobbler/src/musicbrainz/artist.rs
···
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Deserialize, Clone)] 4 + pub struct Artist { 5 + pub name: String, 6 + #[serde(rename = "sort-name")] 7 + pub sort_name: String, 8 + pub r#type: Option<String>, 9 + #[serde(rename = "type-id")] 10 + pub type_id: Option<String>, 11 + #[serde(rename = "life-span")] 12 + pub life_span: Option<LifeSpan>, 13 + pub isnis: Option<Vec<String>>, 14 + pub ipis: Option<Vec<String>>, 15 + pub id: String, 16 + #[serde(rename = "gender-id")] 17 + pub gender_id: Option<String>, 18 + pub gender: Option<String>, 19 + #[serde(rename = "end_area")] 20 + pub end_area: Option<Area>, 21 + #[serde(rename = "end-area")] 22 + pub end_area_: Option<Area>, 23 + pub disambiguation: Option<String>, 24 + pub country: Option<String>, 25 + pub begin_area: Option<Area>, 26 + #[serde(rename = "begin-area")] 27 + pub begin_area_: Option<Area>, 28 + pub area: Option<Area>, 29 + pub aliases: Option<Vec<Alias>>, 30 + } 31 + 32 + #[derive(Debug, Deserialize, Clone)] 33 + pub struct ArtistCredit { 34 + pub joinphrase: Option<String>, 35 + pub name: String, 36 + pub artist: Artist, 37 + } 38 + 39 + #[derive(Debug, Deserialize, Clone)] 40 + pub struct Alias { 41 + pub name: String, 42 + #[serde(rename = "sort-name")] 43 + pub sort_name: String, 44 + pub locale: Option<String>, 45 + pub primary: Option<bool>, 46 + pub r#type: Option<String>, 47 + #[serde(rename = "type-id")] 48 + pub type_id: Option<String>, 49 + pub begin: Option<String>, 50 + pub end: Option<String>, 51 + pub ended: Option<bool>, 52 + } 53 + 54 + #[derive(Debug, Deserialize, Clone)] 55 + pub struct Area { 56 + pub disambiguation: Option<String>, 57 + pub id: String, 58 + pub name: String, 59 + #[serde(rename = "sort-name")] 60 + pub sort_name: String, 61 + pub r#type: Option<String>, 62 + #[serde(rename = "type-id")] 63 + pub type_id: Option<String>, 64 + #[serde(rename = "iso-3166-1-codes")] 65 + pub iso_3166_1_codes: Option<Vec<String>>, 66 + } 67 + 68 + #[derive(Debug, Deserialize, Clone)] 69 + pub struct LifeSpan { 70 + pub begin: Option<String>, 71 + pub end: Option<String>, 72 + pub ended: Option<bool>, 73 + } 74 + 75 + #[derive(Debug, Serialize, Deserialize)] 76 + pub struct Params { 77 + pub inc: Option<String>, 78 + } 79 + 80 + #[derive(Debug, Deserialize)] 81 + pub struct Artists { 82 + pub created: String, 83 + pub count: u32, 84 + pub offset: u32, 85 + pub artists: Vec<Artist>, 86 + }
+56
crates/webscrobbler/src/musicbrainz/client.rs
···
··· 1 + use super::recording::{Recording, Recordings}; 2 + use anyhow::Error; 3 + 4 + pub const BASE_URL: &str = "https://musicbrainz.org/ws/2"; 5 + pub const USER_AGENT: &str = "Rocksky/0.1.0"; 6 + 7 + pub struct MusicbrainzClient {} 8 + 9 + impl MusicbrainzClient { 10 + pub fn new() -> Self { 11 + MusicbrainzClient {} 12 + } 13 + 14 + pub async fn search( 15 + &self, 16 + query: &str, 17 + ) -> Result<Recordings, Error> { 18 + let url = format!("{}/recording", BASE_URL); 19 + let client = reqwest::Client::new(); 20 + let response = client 21 + .get(&url) 22 + .header("Accept", "application/json") 23 + .header("User-Agent", USER_AGENT) 24 + .query( 25 + &[ 26 + ("query", query), 27 + ("inc", "artist-credits+releases"), 28 + ], 29 + ) 30 + .send() 31 + .await?; 32 + 33 + Ok(response.json().await?) 34 + } 35 + 36 + pub async fn get_recording( 37 + &self, 38 + mbid: &str, 39 + ) -> Result<Recording, Error> { 40 + let url = format!("{}/recording/{}", BASE_URL, mbid); 41 + let client = reqwest::Client::new(); 42 + let response = client 43 + .get(&url) 44 + .header("Accept", "application/json") 45 + .header("User-Agent", USER_AGENT) 46 + .query( 47 + &[ 48 + ("inc", "artist-credits+releases"), 49 + ], 50 + ) 51 + .send() 52 + .await?; 53 + 54 + Ok(response.json().await?) 55 + } 56 + }
+30
crates/webscrobbler/src/musicbrainz/label.rs
···
··· 1 + use serde::Deserialize; 2 + 3 + use super::artist::{Area, LifeSpan}; 4 + 5 + #[derive(Debug, Deserialize, Clone)] 6 + pub struct Label { 7 + #[serde(rename = "type-id")] 8 + pub type_id: String, 9 + pub disambiguation: String, 10 + #[serde(rename = "label-code")] 11 + pub label_code: u32, 12 + #[serde(rename = "sort-name")] 13 + pub sort_name: String, 14 + pub id: String, 15 + pub name: String, 16 + pub r#type: String, 17 + pub area: Option<Area>, 18 + pub country: Option<String>, 19 + pub isnis: Option<Vec<String>>, 20 + pub ipis: Option<Vec<String>>, 21 + #[serde(rename = "life-span")] 22 + pub life_span: Option<LifeSpan>, 23 + } 24 + 25 + #[derive(Debug, Deserialize, Clone)] 26 + pub struct LabelInfo { 27 + #[serde(rename = "catalog-number")] 28 + pub catalog_number: String, 29 + pub label: Label, 30 + }
+5
crates/webscrobbler/src/musicbrainz/mod.rs
···
··· 1 + pub mod artist; 2 + pub mod client; 3 + pub mod label; 4 + pub mod recording; 5 + pub mod release;
+25
crates/webscrobbler/src/musicbrainz/recording.rs
···
··· 1 + use serde::Deserialize; 2 + 3 + use super::{artist::ArtistCredit, release::Release}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + pub struct Recordings { 7 + pub recordings: Vec<Recording>, 8 + pub count: u32, 9 + pub offset: u32, 10 + pub created: String, 11 + } 12 + 13 + #[derive(Debug, Deserialize, Clone)] 14 + pub struct Recording { 15 + #[serde(rename = "first-release-date")] 16 + pub first_release_date: Option<String>, 17 + pub title: String, 18 + pub disambiguation: Option<String>, 19 + pub video: Option<bool>, 20 + #[serde(rename = "artist-credit")] 21 + pub artist_credit: Option<Vec<ArtistCredit>>, 22 + pub id: String, 23 + pub length: Option<u32>, 24 + pub releases: Option<Vec<Release>>, 25 + }
+92
crates/webscrobbler/src/musicbrainz/release.rs
···
··· 1 + use serde::Deserialize; 2 + 3 + use super::{ 4 + artist::{Area, ArtistCredit}, 5 + label::LabelInfo, 6 + recording::Recording, 7 + }; 8 + 9 + #[derive(Debug, Deserialize, Clone)] 10 + pub struct Release { 11 + #[serde(rename = "release-events")] 12 + pub release_events: Option<Vec<ReleaseEvent>>, 13 + pub quality: Option<String>, 14 + #[serde(rename = "text-representation")] 15 + pub text_representation: Option<TextRepresentation>, 16 + pub status: Option<String>, 17 + pub packaging: Option<String>, 18 + pub barcode: Option<String>, 19 + pub id: String, 20 + #[serde(rename = "packaging-id")] 21 + pub packaging_id: Option<String>, 22 + pub media: Option<Vec<Media>>, 23 + pub disambiguation: Option<String>, 24 + #[serde(rename = "cover-art-archive")] 25 + pub cover_art_archive: Option<CoverArtArchive>, 26 + #[serde(rename = "artist-credit")] 27 + pub artist_credit: Vec<ArtistCredit>, 28 + #[serde(rename = "status-id")] 29 + pub status_id: Option<String>, 30 + #[serde(rename = "label-info")] 31 + pub label_info: Option<Vec<LabelInfo>>, 32 + pub title: String, 33 + pub date: Option<String>, 34 + pub country: Option<String>, 35 + pub asin: Option<String>, 36 + } 37 + 38 + #[derive(Debug, Deserialize, Clone)] 39 + pub struct CoverArtArchive { 40 + pub back: bool, 41 + pub artwork: bool, 42 + pub front: bool, 43 + pub count: u32, 44 + pub darkened: bool, 45 + } 46 + 47 + #[derive(Debug, Deserialize, Clone)] 48 + pub struct ReleaseEvent { 49 + pub area: Option<Area>, 50 + pub date: String, 51 + } 52 + 53 + #[derive(Debug, Deserialize, Clone)] 54 + pub struct TextRepresentation { 55 + pub language: Option<String>, 56 + pub script: Option<String>, 57 + } 58 + 59 + #[derive(Debug, Deserialize, Clone)] 60 + pub struct Media { 61 + #[serde(rename = "format-id")] 62 + pub format_id: Option<String>, 63 + pub discs: Option<Vec<Disc>>, 64 + pub position: u32, 65 + pub tracks: Option<Vec<Track>>, 66 + #[serde(rename = "track-offset")] 67 + pub track_offset: u32, 68 + pub title: Option<String>, 69 + #[serde(rename = "track-count")] 70 + pub track_count: u32, 71 + pub format: Option<String>, 72 + } 73 + 74 + #[derive(Debug, Deserialize, Clone)] 75 + pub struct Disc { 76 + pub offset: Option<u32>, 77 + pub sectors: u32, 78 + pub id: String, 79 + pub offsets: Option<Vec<u32>>, 80 + } 81 + 82 + #[derive(Debug, Deserialize, Clone)] 83 + pub struct Track { 84 + pub length: i64, 85 + pub id: String, 86 + pub position: u32, 87 + pub title: String, 88 + pub recording: Recording, 89 + #[serde(rename = "artist-credit")] 90 + pub artist_credit: Vec<ArtistCredit>, 91 + pub number: String, 92 + }
+17
crates/webscrobbler/src/repo/album.rs
···
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::album::Album; 5 + 6 + pub async fn get_album_by_track_id(pool: &Pool<Postgres>, track_id: &str) -> Result<Album, Error> { 7 + let results: Vec<Album> = sqlx::query_as(r#" 8 + SELECT * FROM albums 9 + LEFT JOIN album_tracks ON albums.xata_id = album_tracks.album_id 10 + WHERE album_tracks.track_id = $1 11 + "#) 12 + .bind(track_id) 13 + .fetch_all(pool) 14 + .await?; 15 + 16 + Ok(results[0].clone()) 17 + }
+17
crates/webscrobbler/src/repo/artist.rs
···
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::artist::Artist; 5 + 6 + pub async fn get_artist_by_track_id(pool: &Pool<Postgres>, track_id: &str) -> Result<Artist, Error> { 7 + let results: Vec<Artist> = sqlx::query_as(r#" 8 + SELECT * FROM artists 9 + LEFT JOIN artist_tracks ON artists.xata_id = artist_tracks.artist_id 10 + WHERE artist_tracks.track_id = $1 11 + "#) 12 + .bind(track_id) 13 + .fetch_all(pool) 14 + .await?; 15 + 16 + Ok(results[0].clone()) 17 + }
+7
crates/webscrobbler/src/repo/mod.rs
···
··· 1 + pub mod album; 2 + pub mod artist; 3 + pub mod spotify_account; 4 + pub mod spotify_token; 5 + pub mod track; 6 + pub mod user; 7 + pub mod webscrobbler;
+19
crates/webscrobbler/src/repo/spotify_account.rs
···
··· 1 + use sqlx::{Pool, Postgres}; 2 + use anyhow::Error; 3 + use crate::xata::spotify_account::SpotifyAccount; 4 + 5 + pub async fn get_spotify_account(pool: &Pool<Postgres>, user_id: &str) -> Result<Option<SpotifyAccount>, Error> { 6 + let results: Vec<SpotifyAccount> = sqlx::query_as(r#" 7 + SELECT * FROM spotify_accounts 8 + WHERE user_id = $1 9 + "#) 10 + .bind(user_id) 11 + .fetch_all(pool) 12 + .await?; 13 + 14 + if results.len() == 0 { 15 + return Ok(None); 16 + } 17 + 18 + Ok(Some(results[0].clone())) 19 + }
+37
crates/webscrobbler/src/repo/spotify_token.rs
···
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::spotify_token::SpotifyToken; 5 + 6 + 7 + pub async fn get_spotify_token(pool: &Pool<Postgres>, did: &str) -> Result<Option<SpotifyToken>, Error> { 8 + let results: Vec<SpotifyToken> = sqlx::query_as(r#" 9 + SELECT * FROM spotify_tokens 10 + LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 11 + LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 12 + WHERE users.did = $1 13 + "#) 14 + .bind(did) 15 + .fetch_all(pool) 16 + .await?; 17 + 18 + if results.len() == 0 { 19 + return Ok(None); 20 + } 21 + 22 + Ok(Some(results[0].clone())) 23 + } 24 + 25 + pub async fn get_spotify_tokens(pool: &Pool<Postgres>, limit: u32) -> Result<Vec<SpotifyToken>, Error> { 26 + let results: Vec<SpotifyToken> = sqlx::query_as(r#" 27 + SELECT * FROM spotify_tokens 28 + LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 29 + LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 30 + LIMIT $1 31 + "#) 32 + .bind(limit as i32) 33 + .fetch_all(pool) 34 + .await?; 35 + 36 + Ok(results) 37 + }
+37
crates/webscrobbler/src/repo/track.rs
···
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::track::Track; 5 + 6 + pub async fn get_track(pool: &Pool<Postgres>, title: &str, artist: &str) -> Result<Option<Track>, Error> { 7 + let results: Vec<Track> = sqlx::query_as(r#" 8 + SELECT * FROM tracks 9 + WHERE LOWER(title) = LOWER($1) 10 + AND (LOWER(artist) = LOWER($2) OR LOWER(album_artist) = LOWER($2)) 11 + "#) 12 + .bind(title) 13 + .bind(artist) 14 + .fetch_all(pool) 15 + .await?; 16 + 17 + if results.len() == 0 { 18 + return Ok(None); 19 + } 20 + 21 + Ok(Some(results[0].clone())) 22 + } 23 + 24 + pub async fn get_track_by_mbid(pool: &Pool<Postgres>, mbid: &str) -> Result<Option<Track>, Error> { 25 + let results: Vec<Track> = sqlx::query_as(r#" 26 + SELECT * FROM tracks WHERE mb_id = $1 27 + "#) 28 + .bind(mbid) 29 + .fetch_all(pool) 30 + .await?; 31 + 32 + if results.len() == 0 { 33 + return Ok(None); 34 + } 35 + 36 + Ok(Some(results[0].clone())) 37 + }
+22
crates/webscrobbler/src/repo/user.rs
···
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::user::User; 5 + 6 + 7 + pub async fn get_user_by_webscrobbler(pool: &Pool<Postgres>, uuid: &str) -> Result<Option<User>, Error> { 8 + let results: Vec<User> = sqlx::query_as(r#" 9 + SELECT * FROM users 10 + LEFT JOIN webscrobblers ON users.xata_id = webscrobblers.user_id 11 + WHERE webscrobblers.uuid = $1 12 + "#) 13 + .bind(uuid) 14 + .fetch_all(pool) 15 + .await?; 16 + 17 + if results.len() == 0 { 18 + return Ok(None); 19 + } 20 + 21 + Ok(Some(results[0].clone())) 22 + }
+19
crates/webscrobbler/src/repo/webscrobbler.rs
···
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + use crate::xata::webscrobbler::Webscrobbler; 4 + 5 + pub async fn get_webscrobbler(pool: &Pool<Postgres>, uuid: &str) -> Result<Option<Webscrobbler>, Error> { 6 + let results: Vec<Webscrobbler> = sqlx::query_as(r#" 7 + SELECT * FROM webscrobblers 8 + WHERE uuid = $1 9 + "#) 10 + .bind(uuid) 11 + .fetch_all(pool) 12 + .await?; 13 + 14 + if results.len() == 0 { 15 + return Ok(None); 16 + } 17 + 18 + Ok(Some(results[0].clone())) 19 + }
+38
crates/webscrobbler/src/rocksky.rs
···
··· 1 + use anyhow::Error; 2 + use reqwest::Client; 3 + 4 + use crate::{auth::generate_token, cache::Cache, types::Track}; 5 + 6 + const ROCKSKY_API: &str = "https://api.rocksky.app"; 7 + 8 + pub async fn scrobble(cache: &Cache, did: &str, track: Track, timestamp: u64) -> Result<(), Error> { 9 + let key = format!("{} - {}", track.artist.to_lowercase(), track.title.to_lowercase()); 10 + 11 + // Check if the track is already in the cache, if not add it 12 + if !cache.exists(&key)? { 13 + let value = serde_json::to_string(&track)?; 14 + let ttl = 15 * 60; // 15 minutes 15 + cache.setex(&key, &value, ttl)?; 16 + } 17 + 18 + let mut track = track; 19 + track.timestamp = Some(timestamp / 1000 as u64); 20 + 21 + let token = generate_token(did)?; 22 + let client = Client::new(); 23 + 24 + println!("Scrobbling track: \n {:#?}", track); 25 + 26 + let response= client 27 + .post(&format!("{}/now-playing", ROCKSKY_API)) 28 + .bearer_auth(token) 29 + .json(&track) 30 + .send() 31 + .await?; 32 + 33 + if !response.status().is_success() { 34 + return Err(Error::msg(format!("Failed to scrobble track: {}", response.text().await?))); 35 + } 36 + 37 + Ok(()) 38 + }
+113
crates/webscrobbler/src/scrobbler.rs
···
··· 1 + use std::env; 2 + 3 + use owo_colors::OwoColorize; 4 + use rand::Rng; 5 + use sqlx::{Pool, Postgres}; 6 + use anyhow::Error; 7 + use crate::cache::Cache; 8 + use crate::crypto::decrypt_aes_256_ctr; 9 + use crate::musicbrainz::client::MusicbrainzClient; 10 + use crate::spotify::client::SpotifyClient; 11 + use crate::spotify::refresh_token; 12 + use crate::{repo, rocksky}; 13 + use crate::types::{ScrobbleRequest, Track}; 14 + 15 + pub async fn scrobble(pool: &Pool<Postgres>, cache: &Cache, scrobble: ScrobbleRequest, did: &str) -> Result<(), Error> { 16 + let spofity_tokens = repo::spotify_token::get_spotify_tokens(pool, 100).await?; 17 + 18 + if spofity_tokens.is_empty() { 19 + return Err(Error::msg("No Spotify tokens found")); 20 + } 21 + 22 + let mb_client = MusicbrainzClient::new(); 23 + 24 + let key = format!("{} - {}", scrobble.data.song.parsed.artist.to_lowercase(), scrobble.data.song.parsed.track.to_lowercase()); 25 + 26 + let cached = cache.get(&key)?; 27 + if cached.is_some() { 28 + println!("{}", format!("Cached: {}", key).yellow()); 29 + let track = serde_json::from_str::<Track>(&cached.unwrap())?; 30 + rocksky::scrobble(cache, &did, track, scrobble.time).await?; 31 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 32 + return Ok(()); 33 + } 34 + 35 + let result = repo::track::get_track(pool, &scrobble.data.song.parsed.track, &scrobble.data.song.parsed.artist).await?; 36 + 37 + if let Some(track) = result { 38 + println!("{}", "Xata (track)".yellow()); 39 + let album = repo::album::get_album_by_track_id(pool, &track.xata_id).await?; 40 + let artist = repo::artist::get_artist_by_track_id(pool, &track.xata_id).await?; 41 + let mut track: Track = track.into(); 42 + track.year = match album.year { 43 + Some(year) => Some(year as u32), 44 + None => match album.release_date.clone() { 45 + Some(release_date) => { 46 + let year = release_date.split("-").next(); 47 + year.and_then(|x| x.parse::<u32>().ok()) 48 + } 49 + None => None, 50 + }, 51 + }; 52 + track.release_date = album.release_date.map(|x| x.split("T").next().unwrap().to_string()); 53 + track.artist_picture = artist.picture.clone(); 54 + 55 + rocksky::scrobble(cache, &did, track, scrobble.time).await?; 56 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 57 + return Ok(()); 58 + } 59 + 60 + // we need to pick a random token to avoid Spotify rate limiting 61 + // and to avoid using the same token for all scrobbles 62 + // this is a simple way to do it, but we can improve it later 63 + // by using a more sophisticated algorithm 64 + // or by using a token pool 65 + let mut rng = rand::rng(); 66 + let random_index = rng.random_range(0..spofity_tokens.len()); 67 + let spotify_token = &spofity_tokens[random_index]; 68 + 69 + let spotify_token = decrypt_aes_256_ctr( 70 + &spotify_token.refresh_token, 71 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? 72 + )?; 73 + 74 + let spotify_token = refresh_token(&spotify_token).await?; 75 + let spotify_client = SpotifyClient::new(&spotify_token.access_token); 76 + 77 + let result = spotify_client.search(&format!(r#"track:"{}" artist:"{}""#, scrobble.data.song.parsed.track, scrobble.data.song.parsed.artist)).await?; 78 + 79 + if let Some(track) = result.tracks.items.first() { 80 + println!("{}", "Spotify (track)".yellow()); 81 + let mut track = track.clone(); 82 + 83 + if let Some(album) = spotify_client.get_album(&track.album.id).await? { 84 + track.album = album; 85 + } 86 + 87 + if let Some(artist) = spotify_client.get_artist(&track.album.artists[0].id).await? { 88 + track.album.artists[0] = artist; 89 + } 90 + 91 + rocksky::scrobble(cache, &did, track.into(), scrobble.time).await?; 92 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 93 + return Ok(()); 94 + } 95 + 96 + let query = format!( 97 + r#"recording:"{}" AND artist:"{}""#, 98 + scrobble.data.song.parsed.track, scrobble.data.song.parsed.artist 99 + ); 100 + let result = mb_client.search(&query).await?; 101 + 102 + if let Some(recording) = result.recordings.first() { 103 + let result = mb_client.get_recording(&recording.id).await?; 104 + println!("{}", "Musicbrainz (recording)".yellow()); 105 + rocksky::scrobble(cache, &did, result.into(), scrobble.time).await?; 106 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 107 + return Ok(()); 108 + } 109 + 110 + println!("{} {} - {}, skipping", "Track not found: ".yellow(), scrobble.data.song.parsed.artist, scrobble.data.song.parsed.track); 111 + 112 + Ok(()) 113 + }
+68
crates/webscrobbler/src/spotify/client.rs
···
··· 1 + use super::types::{Album, Artist, SearchResponse}; 2 + use anyhow::Error; 3 + 4 + pub const BASE_URL: &str = "https://api.spotify.com/v1"; 5 + 6 + pub struct SpotifyClient { 7 + token: String, 8 + } 9 + 10 + impl SpotifyClient { 11 + pub fn new(token: &str) -> Self { 12 + SpotifyClient { 13 + token: token.to_string(), 14 + } 15 + } 16 + 17 + pub async fn search(&self, query: &str) -> Result<SearchResponse, Error> { 18 + let url = format!("{}/search", BASE_URL); 19 + let client = reqwest::Client::new(); 20 + let response = client.get(&url) 21 + .bearer_auth(&self.token) 22 + .query(&[ 23 + ("type", "track"), 24 + ("q", query), 25 + ]) 26 + .send().await?; 27 + let result = response.json().await?; 28 + Ok(result) 29 + } 30 + 31 + pub async fn get_album(&self, id: &str) -> Result<Option<Album>, Error> { 32 + let url = format!("{}/albums/{}", BASE_URL, id); 33 + let client = reqwest::Client::new(); 34 + let response = client.get(&url) 35 + .bearer_auth(&self.token) 36 + .send().await?; 37 + 38 + let headers = response.headers().clone(); 39 + let data = response.text().await?; 40 + 41 + if data == "Too many requests" { 42 + println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); 43 + println!("> {} [get_album]", data); 44 + return Ok(None); 45 + } 46 + 47 + Ok(Some(serde_json::from_str(&data)?)) 48 + } 49 + 50 + pub async fn get_artist(&self, id: &str) -> Result<Option<Artist>, Error> { 51 + let url = format!("{}/artists/{}", BASE_URL, id); 52 + let client = reqwest::Client::new(); 53 + let response = client.get(&url) 54 + .bearer_auth(&self.token) 55 + .send().await?; 56 + 57 + let headers = response.headers().clone(); 58 + let data = response.text().await?; 59 + 60 + if data == "Too many requests" { 61 + println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); 62 + println!("> {} [get_artist]", data); 63 + return Ok(None); 64 + } 65 + 66 + Ok(Some(serde_json::from_str(&data)?)) 67 + } 68 + }
+32
crates/webscrobbler/src/spotify/mod.rs
···
··· 1 + use std::env; 2 + 3 + use reqwest::Client; 4 + use types::AccessToken; 5 + use anyhow::Error; 6 + 7 + pub mod client; 8 + pub mod types; 9 + 10 + 11 + pub async fn refresh_token(token: &str) -> Result<AccessToken, Error> { 12 + if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { 13 + panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); 14 + } 15 + 16 + let client_id = env::var("SPOTIFY_CLIENT_ID")?; 17 + let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; 18 + 19 + let client = Client::new(); 20 + 21 + let response = client.post("https://accounts.spotify.com/api/token") 22 + .basic_auth(&client_id, Some(client_secret)) 23 + .form(&[ 24 + ("grant_type", "refresh_token"), 25 + ("refresh_token", token), 26 + ("client_id", &client_id) 27 + ]) 28 + .send() 29 + .await?; 30 + let token = response.json::<AccessToken>().await?; 31 + Ok(token) 32 + }
+104
crates/webscrobbler/src/spotify/types.rs
···
··· 1 + use serde::Deserialize; 2 + 3 + #[derive(Debug, Deserialize, Clone)] 4 + pub struct SearchResponse { 5 + pub tracks: Tracks, 6 + } 7 + 8 + #[derive(Debug, Deserialize, Clone)] 9 + pub struct Tracks { 10 + pub href: String, 11 + pub limit: u32, 12 + pub next: Option<String>, 13 + pub offset: u32, 14 + pub previous: Option<String>, 15 + pub total: u32, 16 + pub items: Vec<Track>, 17 + } 18 + 19 + #[derive(Debug, Deserialize, Clone)] 20 + pub struct Track { 21 + pub album: Album, 22 + pub artists: Vec<Artist>, 23 + pub available_markets: Vec<String>, 24 + pub disc_number: u32, 25 + pub duration_ms: u32, 26 + pub explicit: bool, 27 + pub external_ids: ExternalIds, 28 + pub external_urls: ExternalUrls, 29 + pub href: String, 30 + pub id: String, 31 + pub is_local: bool, 32 + pub is_playable: Option<bool>, 33 + pub name: String, 34 + pub popularity: u32, 35 + pub preview_url: Option<String>, 36 + pub track_number: u32, 37 + #[serde(rename = "type")] 38 + pub kind: String, 39 + pub uri: String, 40 + } 41 + 42 + #[derive(Debug, Deserialize, Clone)] 43 + pub struct Album { 44 + pub album_type: String, 45 + pub artists: Vec<Artist>, 46 + pub available_markets: Vec<String>, 47 + pub external_urls: ExternalUrls, 48 + pub href: String, 49 + pub id: String, 50 + pub images: Vec<Image>, 51 + pub name: String, 52 + pub release_date: String, 53 + pub release_date_precision: String, 54 + pub total_tracks: u32, 55 + #[serde(rename = "type")] 56 + pub album_type_field: String, 57 + pub uri: String, 58 + pub label: Option<String>, 59 + pub genres: Option<Vec<String>>, 60 + pub copyrights: Option<Vec<Copyright>>, 61 + } 62 + 63 + #[derive(Debug, Deserialize, Clone)] 64 + pub struct Copyright { 65 + pub text: String, 66 + pub r#type: String, 67 + } 68 + 69 + #[derive(Debug, Deserialize, Clone)] 70 + pub struct Artist { 71 + pub external_urls: ExternalUrls, 72 + pub href: String, 73 + pub id: String, 74 + pub name: String, 75 + #[serde(rename = "type")] 76 + pub kind: String, 77 + pub uri: String, 78 + pub images: Option<Vec<Image>>, 79 + } 80 + 81 + #[derive(Debug, Deserialize, Clone)] 82 + pub struct ExternalUrls { 83 + pub spotify: String, 84 + } 85 + 86 + #[derive(Debug, Deserialize, Clone)] 87 + pub struct ExternalIds { 88 + pub isrc: String, 89 + } 90 + 91 + #[derive(Debug, Deserialize, Clone)] 92 + pub struct Image { 93 + pub height: u32, 94 + pub width: u32, 95 + pub url: String, 96 + } 97 + 98 + #[derive(Debug, Deserialize, Clone)] 99 + pub struct AccessToken { 100 + pub access_token: String, 101 + pub token_type: String, 102 + pub scope: String, 103 + pub expires_in: u32, 104 + }
+590
crates/webscrobbler/src/types.rs
···
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use crate::{musicbrainz, spotify, xata}; 4 + 5 + #[derive(Deserialize, Debug, Clone)] 6 + pub struct Connector { 7 + pub id: String, 8 + pub js: String, 9 + pub label: String, 10 + pub matches: Vec<String>, 11 + } 12 + 13 + #[derive(Deserialize, Debug, Clone)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct IsRegrexEditedByUser { 16 + pub album: bool, 17 + pub album_artist: bool, 18 + pub artist: bool, 19 + pub track: bool, 20 + } 21 + 22 + #[derive(Deserialize, Debug, Clone)] 23 + #[serde(rename_all = "camelCase")] 24 + pub struct Flags { 25 + pub finished_processing: bool, 26 + pub has_blocked_tag: bool, 27 + pub is_album_fetched: bool, 28 + pub is_corrected_by_user: bool, 29 + pub is_loved_in_service: Option<bool>, 30 + pub is_marked_as_playing: bool, 31 + pub is_regex_edited_by_user: IsRegrexEditedByUser, 32 + pub is_replaying: bool, 33 + pub is_scrobbled: bool, 34 + pub is_skipped: bool, 35 + pub is_valid: bool, 36 + } 37 + 38 + #[derive(Deserialize, Debug, Clone)] 39 + #[serde(rename_all = "camelCase")] 40 + pub struct Metadata { 41 + pub album_url: Option<String>, 42 + pub artist_url: Option<String>, 43 + pub label: String, 44 + pub start_timestamp: u64, 45 + pub track_url: Option<String>, 46 + pub user_play_count: u32, 47 + pub userloved: bool, 48 + } 49 + 50 + #[derive(Deserialize, Debug, Clone)] 51 + #[serde(rename_all = "camelCase")] 52 + pub struct NoRegex { 53 + pub album: String, 54 + pub album_artist: Option<String>, 55 + pub artist: String, 56 + pub duration: Option<u32>, 57 + pub track: String, 58 + } 59 + 60 + #[derive(Deserialize, Debug, Clone)] 61 + #[serde(rename_all = "camelCase")] 62 + pub struct Parsed { 63 + pub album: String, 64 + pub album_artist: Option<String>, 65 + pub artist: String, 66 + pub current_time: Option<u32>, 67 + pub duration: u32, 68 + pub is_playing: bool, 69 + pub is_podcast: bool, 70 + pub origin_url: Option<String>, 71 + pub scrobbling_disallowed_reason: Option<String>, 72 + pub track: String, 73 + pub track_art: Option<String>, 74 + #[serde(rename = "uniqueID")] 75 + pub unique_id: Option<String>, 76 + } 77 + 78 + #[derive(Deserialize, Debug, Clone)] 79 + #[serde(rename_all = "camelCase")] 80 + pub struct Song { 81 + pub connector: Connector, 82 + pub controller_tab_id: u64, 83 + pub flags: Flags, 84 + pub metadata: Metadata, 85 + pub no_regex: NoRegex, 86 + pub parsed: Parsed, 87 + } 88 + 89 + #[derive(Deserialize, Debug, Clone)] 90 + #[serde(rename_all = "camelCase")] 91 + pub struct Processed { 92 + pub album: String, 93 + pub album_artist: Option<String>, 94 + pub artist: String, 95 + pub duration: u32, 96 + pub track: String, 97 + } 98 + 99 + #[derive(Deserialize, Debug, Clone)] 100 + #[serde(rename_all = "camelCase")] 101 + pub struct Scrobble { 102 + pub song: Song, 103 + } 104 + 105 + #[derive(Deserialize, Debug, Clone)] 106 + #[serde(rename_all = "camelCase")] 107 + pub struct ScrobbleRequest { 108 + pub data: Scrobble, 109 + pub event_name: String, 110 + pub time: u64, 111 + } 112 + 113 + #[derive(Debug, Serialize, Deserialize, Default)] 114 + #[serde(rename_all = "camelCase")] 115 + pub struct Track { 116 + pub title: String, 117 + pub album: String, 118 + pub artist: String, 119 + pub album_artist: Option<String>, 120 + pub duration: u32, 121 + pub mbid: Option<String>, 122 + pub track_number: u32, 123 + pub release_date: Option<String>, 124 + pub year: Option<u32>, 125 + pub disc_number: u32, 126 + pub album_art: Option<String>, 127 + pub spotify_link: Option<String>, 128 + pub label: Option<String>, 129 + pub artist_picture: Option<String>, 130 + pub timestamp: Option<u64>, 131 + } 132 + 133 + impl From<xata::track::Track> for Track { 134 + fn from(track: xata::track::Track) -> Self { 135 + Track { 136 + title: track.title, 137 + album: track.album, 138 + artist: track.artist, 139 + album_artist: Some(track.album_artist), 140 + album_art: track.album_art, 141 + spotify_link: track.spotify_link, 142 + label: track.label, 143 + artist_picture: None, 144 + timestamp: None, 145 + duration: track.duration as u32, 146 + mbid: track.mb_id, 147 + track_number: track.track_number as u32, 148 + disc_number: track.disc_number as u32, 149 + year: None, 150 + release_date: None, 151 + } 152 + } 153 + } 154 + 155 + impl From<musicbrainz::recording::Recording> for Track { 156 + fn from(recording: musicbrainz::recording::Recording) -> Self { 157 + let artist_credit = recording 158 + .artist_credit 159 + .unwrap_or_default() 160 + .first() 161 + .map(|credit| credit.name.clone()) 162 + .unwrap_or_default(); 163 + let releases = recording.releases.unwrap_or_default(); 164 + let album_artist = releases 165 + .first() 166 + .and_then(|release| release.artist_credit.first()) 167 + .map(|credit| credit.name.clone()); 168 + let album = releases 169 + .first() 170 + .map(|release| release.title.clone()) 171 + .unwrap_or_default(); 172 + Track { 173 + title: recording.title.clone(), 174 + album, 175 + artist: artist_credit, 176 + album_artist, 177 + duration: recording.length.unwrap_or_default(), 178 + year: recording 179 + .first_release_date 180 + .as_ref() 181 + .and_then(|date| date.split('-').next()) 182 + .and_then(|year| year.parse::<u32>().ok()), 183 + release_date: recording.first_release_date.clone(), 184 + track_number: releases 185 + .first() 186 + .and_then(|release| { 187 + release 188 + .media 189 + .as_ref() 190 + .and_then(|media| media.first()) 191 + .and_then(|media| { 192 + media 193 + .tracks 194 + .as_ref() 195 + .and_then(|tracks| tracks.first()) 196 + .map(|track| track.number.parse::<u32>().unwrap()) 197 + }) 198 + }) 199 + .unwrap_or_default(), 200 + disc_number: releases 201 + .first() 202 + .and_then(|release| { 203 + release 204 + .media 205 + .as_ref() 206 + .and_then(|media| media.first()) 207 + .map(|media| media.position) 208 + }) 209 + .unwrap_or_default(), 210 + ..Default::default() 211 + } 212 + } 213 + } 214 + 215 + impl From<&spotify::types::Track> for Track { 216 + fn from(track: &spotify::types::Track) -> Self { 217 + Track { 218 + title: track.name.clone(), 219 + album: track.album.name.clone(), 220 + artist: track 221 + .artists 222 + .iter() 223 + .map(|artist| artist.name.clone()) 224 + .collect::<Vec<_>>() 225 + .join(", "), 226 + album_artist: track 227 + .album 228 + .artists 229 + .first() 230 + .map(|artist| artist.name.clone()), 231 + duration: track.duration_ms as u32, 232 + album_art: track.album.images.first().map(|image| image.url.clone()), 233 + spotify_link: Some(track.external_urls.spotify.clone()), 234 + artist_picture: track.album.artists.first().and_then(|artist| { 235 + artist 236 + .images 237 + .as_ref() 238 + .and_then(|images| images.first().map(|image| image.url.clone())) 239 + }), 240 + track_number: track.track_number, 241 + disc_number: track.disc_number, 242 + release_date: match track.album.release_date_precision.as_str() { 243 + "day" => Some(track.album.release_date.clone()), 244 + _ => None, 245 + }, 246 + year: match track.album.release_date_precision.as_str() { 247 + "day" => Some( 248 + track 249 + .album 250 + .release_date 251 + .split('-') 252 + .next() 253 + .unwrap() 254 + .parse::<u32>() 255 + .unwrap(), 256 + ), 257 + "year" => Some(track.album.release_date.parse::<u32>().unwrap()), 258 + _ => None, 259 + }, 260 + label: track.album.label.clone(), 261 + ..Default::default() 262 + } 263 + } 264 + } 265 + 266 + impl From<spotify::types::Track> for Track { 267 + fn from(track: spotify::types::Track) -> Self { 268 + Track::from(&track) 269 + } 270 + } 271 + 272 + #[cfg(test)] 273 + mod tests { 274 + use super::*; 275 + 276 + #[test] 277 + fn test_tidal_scrobble_request() { 278 + let json = r#" 279 + { 280 + "data": { 281 + "song": { 282 + "connector": { 283 + "id": "tidal", 284 + "js": "tidal.js", 285 + "label": "Tidal", 286 + "matches": [ 287 + "*://listen.tidalhifi.com/*", 288 + "*://listen.tidal.com/*" 289 + ] 290 + }, 291 + "controllerTabId": 2105806618, 292 + "flags": { 293 + "finishedProcessing": true, 294 + "hasBlockedTag": false, 295 + "isAlbumFetched": false, 296 + "isCorrectedByUser": false, 297 + "isLovedInService": null, 298 + "isMarkedAsPlaying": true, 299 + "isRegexEditedByUser": { 300 + "album": false, 301 + "albumArtist": false, 302 + "artist": false, 303 + "track": false 304 + }, 305 + "isReplaying": false, 306 + "isScrobbled": false, 307 + "isSkipped": false, 308 + "isValid": true 309 + }, 310 + "metadata": { 311 + "albumUrl": "https://www.last.fm/music/Tee+Grizzley/Forever+My+Moment+%5BClean%5D+%5BClean%5D", 312 + "artistUrl": "https://www.last.fm/music/Tee+Grizzley", 313 + "label": "Tidal", 314 + "startTimestamp": 1747766980, 315 + "trackUrl": "https://www.last.fm/music/Tee+Grizzley/_/Forever+My+Moment", 316 + "userPlayCount": 0, 317 + "userloved": false 318 + }, 319 + "noRegex": { 320 + "album": "FOREVER MY MOMENT", 321 + "albumArtist": null, 322 + "artist": "Tee Grizzley", 323 + "duration": null, 324 + "track": "Forever My Moment" 325 + }, 326 + "parsed": { 327 + "album": "FOREVER MY MOMENT", 328 + "albumArtist": null, 329 + "artist": "Tee Grizzley", 330 + "currentTime": 17, 331 + "duration": 182, 332 + "isPlaying": false, 333 + "isPodcast": false, 334 + "originUrl": "https://listen.tidal.com/", 335 + "scrobblingDisallowedReason": null, 336 + "track": "Forever My Moment", 337 + "trackArt": "https://resources.tidal.com/images/275251bf/9f03/46bf/9e46/3a3b0a67abe6/80x80.jpg", 338 + "uniqueID": "434750253" 339 + }, 340 + "processed": { 341 + "album": "FOREVER MY MOMENT", 342 + "albumArtist": null, 343 + "artist": "Tee Grizzley", 344 + "duration": 182, 345 + "track": "Forever My Moment" 346 + } 347 + } 348 + }, 349 + "eventName": "paused", 350 + "time": 1747766997907 351 + } 352 + "#; 353 + 354 + let result = serde_json::from_str::<ScrobbleRequest>(json); 355 + assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err()); 356 + } 357 + 358 + #[test] 359 + fn test_spotify_nowplaying_request() { 360 + let json = r#" 361 + { 362 + "data": { 363 + "song": { 364 + "connector": { 365 + "hasNativeScrobbler": true, 366 + "id": "spotify", 367 + "js": "spotify.js", 368 + "label": "Spotify", 369 + "matches": [ 370 + "*://open.spotify.com/*" 371 + ] 372 + }, 373 + "controllerTabId": 2105804433, 374 + "flags": { 375 + "finishedProcessing": true, 376 + "hasBlockedTag": false, 377 + "isAlbumFetched": false, 378 + "isCorrectedByUser": false, 379 + "isLovedInService": null, 380 + "isMarkedAsPlaying": true, 381 + "isRegexEditedByUser": { 382 + "album": false, 383 + "albumArtist": false, 384 + "artist": false, 385 + "track": false 386 + }, 387 + "isReplaying": false, 388 + "isScrobbled": false, 389 + "isSkipped": false, 390 + "isValid": true 391 + }, 392 + "metadata": { 393 + "albumUrl": "https://www.last.fm/music/The+Weeknd/Hurry+Up+Tomorrow+(First+Press)", 394 + "artistUrl": "https://www.last.fm/music/The+Weeknd", 395 + "label": "Spotify", 396 + "startTimestamp": 1747753805, 397 + "trackArtUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/eadb0529b2c5066ebe7f53c52e329def.png", 398 + "trackUrl": "https://www.last.fm/music/The+Weeknd/_/Given+Up+on+Me", 399 + "userPlayCount": 0, 400 + "userloved": false 401 + }, 402 + "noRegex": { 403 + "album": "Hurry Up Tomorrow", 404 + "albumArtist": null, 405 + "artist": "The Weeknd", 406 + "duration": null, 407 + "track": "Given Up On Me" 408 + }, 409 + "parsed": { 410 + "album": "Hurry Up Tomorrow", 411 + "albumArtist": null, 412 + "artist": "The Weeknd", 413 + "currentTime": null, 414 + "duration": 354, 415 + "isPlaying": true, 416 + "isPodcast": false, 417 + "originUrl": null, 418 + "scrobblingDisallowedReason": null, 419 + "track": "Given Up On Me", 420 + "trackArt": "https://i.scdn.co/image/ab67616d00001e02982320da137d0de34410df61", 421 + "uniqueID": null 422 + }, 423 + "processed": { 424 + "album": "Hurry Up Tomorrow", 425 + "albumArtist": null, 426 + "artist": "The Weeknd", 427 + "duration": 354, 428 + "track": "Given Up on Me" 429 + } 430 + } 431 + }, 432 + "eventName": "nowplaying", 433 + "time": 1747753806195 434 + } 435 + "#; 436 + 437 + let result = serde_json::from_str::<ScrobbleRequest>(json); 438 + assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err()); 439 + } 440 + 441 + #[test] 442 + fn test_spotify_scrobble_request() { 443 + let json = r#" 444 + { 445 + "data": { 446 + "currentlyPlaying": true, 447 + "song": { 448 + "connector": { 449 + "hasNativeScrobbler": true, 450 + "id": "spotify", 451 + "js": "spotify.js", 452 + "label": "Spotify", 453 + "matches": [ 454 + "*://open.spotify.com/*" 455 + ] 456 + }, 457 + "controllerTabId": 2105804433, 458 + "flags": { 459 + "finishedProcessing": true, 460 + "hasBlockedTag": false, 461 + "isAlbumFetched": false, 462 + "isCorrectedByUser": false, 463 + "isLovedInService": null, 464 + "isMarkedAsPlaying": true, 465 + "isRegexEditedByUser": { 466 + "album": false, 467 + "albumArtist": false, 468 + "artist": false, 469 + "track": false 470 + }, 471 + "isReplaying": false, 472 + "isScrobbled": false, 473 + "isSkipped": false, 474 + "isValid": true 475 + }, 476 + "metadata": { 477 + "artistUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory", 478 + "label": "Spotify", 479 + "startTimestamp": 1747753624, 480 + "trackUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory/_/Never+Let+Me+Down", 481 + "userPlayCount": 0, 482 + "userloved": false 483 + }, 484 + "noRegex": { 485 + "album": "Never Let Me Down", 486 + "albumArtist": null, 487 + "artist": "VIZE, Tom Gregory", 488 + "duration": null, 489 + "track": "Never Let Me Down" 490 + }, 491 + "parsed": { 492 + "album": "Never Let Me Down", 493 + "albumArtist": null, 494 + "artist": "VIZE, Tom Gregory", 495 + "currentTime": 76, 496 + "duration": 153, 497 + "isPlaying": true, 498 + "isPodcast": false, 499 + "originUrl": null, 500 + "scrobblingDisallowedReason": null, 501 + "track": "Never Let Me Down", 502 + "trackArt": "https://i.scdn.co/image/ab67616d00001e02e33c4ba1bf5eecbbc7dddc85", 503 + "uniqueID": null 504 + }, 505 + "processed": { 506 + "album": "Never Let Me Down", 507 + "albumArtist": null, 508 + "artist": "VIZE, Tom Gregory", 509 + "duration": 153, 510 + "track": "Never Let Me Down" 511 + } 512 + }, 513 + "songs": [ 514 + { 515 + "connector": { 516 + "hasNativeScrobbler": true, 517 + "id": "spotify", 518 + "js": "spotify.js", 519 + "label": "Spotify", 520 + "matches": [ 521 + "*://open.spotify.com/*" 522 + ] 523 + }, 524 + "controllerTabId": 2105804433, 525 + "flags": { 526 + "finishedProcessing": true, 527 + "hasBlockedTag": false, 528 + "isAlbumFetched": false, 529 + "isCorrectedByUser": false, 530 + "isLovedInService": null, 531 + "isMarkedAsPlaying": true, 532 + "isRegexEditedByUser": { 533 + "album": false, 534 + "albumArtist": false, 535 + "artist": false, 536 + "track": false 537 + }, 538 + "isReplaying": false, 539 + "isScrobbled": false, 540 + "isSkipped": false, 541 + "isValid": true 542 + }, 543 + "metadata": { 544 + "artistUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory", 545 + "label": "Spotify", 546 + "startTimestamp": 1747753624, 547 + "trackUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory/_/Never+Let+Me+Down", 548 + "userPlayCount": 0, 549 + "userloved": false 550 + }, 551 + "noRegex": { 552 + "album": "Never Let Me Down", 553 + "albumArtist": null, 554 + "artist": "VIZE, Tom Gregory", 555 + "duration": null, 556 + "track": "Never Let Me Down" 557 + }, 558 + "parsed": { 559 + "album": "Never Let Me Down", 560 + "albumArtist": null, 561 + "artist": "VIZE, Tom Gregory", 562 + "currentTime": 76, 563 + "duration": 153, 564 + "isPlaying": true, 565 + "isPodcast": false, 566 + "originUrl": null, 567 + "scrobblingDisallowedReason": null, 568 + "track": "Never Let Me Down", 569 + "trackArt": "https://i.scdn.co/image/ab67616d00001e02e33c4ba1bf5eecbbc7dddc85", 570 + "uniqueID": null 571 + }, 572 + "processed": { 573 + "album": "Never Let Me Down", 574 + "albumArtist": null, 575 + "artist": "VIZE, Tom Gregory", 576 + "duration": 153, 577 + "track": "Never Let Me Down" 578 + } 579 + } 580 + ] 581 + }, 582 + "eventName": "scrobble", 583 + "time": 1747753702338 584 + } 585 + "#; 586 + 587 + let result = serde_json::from_str::<ScrobbleRequest>(json); 588 + assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err()); 589 + } 590 + }
+21
crates/webscrobbler/src/xata/album.rs
···
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 + pub struct Album { 6 + pub xata_id: String, 7 + pub title: String, 8 + pub artist: String, 9 + pub release_date: Option<String>, 10 + pub album_art: Option<String>, 11 + pub year: Option<i32>, 12 + pub spotify_link: Option<String>, 13 + pub tidal_link: Option<String>, 14 + pub youtube_link: Option<String>, 15 + pub apple_music_link: Option<String>, 16 + pub sha256: String, 17 + pub uri: Option<String>, 18 + pub artist_uri: Option<String>, 19 + #[serde(with = "chrono::serde::ts_seconds")] 20 + pub xata_createdat: DateTime<Utc>, 21 + }
+23
crates/webscrobbler/src/xata/artist.rs
···
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 + pub struct Artist { 6 + pub xata_id: String, 7 + pub name: String, 8 + pub biography: Option<String>, 9 + #[serde(with = "chrono::serde::ts_seconds_option")] 10 + pub born: Option<DateTime<Utc>>, 11 + pub born_in: Option<String>, 12 + #[serde(with = "chrono::serde::ts_seconds_option")] 13 + pub died: Option<DateTime<Utc>>, 14 + pub picture: Option<String>, 15 + pub sha256: String, 16 + pub spotify_link: Option<String>, 17 + pub tidal_link: Option<String>, 18 + pub youtube_link: Option<String>, 19 + pub apple_music_link: Option<String>, 20 + pub uri: Option<String>, 21 + #[serde(with = "chrono::serde::ts_seconds")] 22 + pub xata_createdat: DateTime<Utc>, 23 + }
+7
crates/webscrobbler/src/xata/mod.rs
···
··· 1 + pub mod album; 2 + pub mod artist; 3 + pub mod spotify_account; 4 + pub mod spotify_token; 5 + pub mod track; 6 + pub mod user; 7 + pub mod webscrobbler;
+15
crates/webscrobbler/src/xata/spotify_account.rs
···
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] 5 + pub struct SpotifyAccount { 6 + pub xata_id: String, 7 + pub xata_version: i32, 8 + #[serde(with = "chrono::serde::ts_seconds")] 9 + pub xata_createdat: DateTime<Utc>, 10 + #[serde(with = "chrono::serde::ts_seconds")] 11 + pub xata_updatedat: DateTime<Utc>, 12 + pub email: String, 13 + pub user_id: String, 14 + pub is_beta_user: bool, 15 + }
+30
crates/webscrobbler/src/xata/spotify_token.rs
···
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] 5 + pub struct SpotifyToken { 6 + pub xata_id: String, 7 + pub xata_version: i32, 8 + #[serde(with = "chrono::serde::ts_seconds")] 9 + pub xata_createdat: DateTime<Utc>, 10 + #[serde(with = "chrono::serde::ts_seconds")] 11 + pub xata_updatedat: DateTime<Utc>, 12 + pub user_id: String, 13 + pub access_token: String, 14 + pub refresh_token: String, 15 + } 16 + 17 + #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] 18 + pub struct SpotifyTokenWithEmail { 19 + pub xata_id: String, 20 + pub xata_version: i32, 21 + #[serde(with = "chrono::serde::ts_seconds")] 22 + pub xata_createdat: DateTime<Utc>, 23 + #[serde(with = "chrono::serde::ts_seconds")] 24 + pub xata_updatedat: DateTime<Utc>, 25 + pub user_id: String, 26 + pub access_token: String, 27 + pub refresh_token: String, 28 + pub email: String, 29 + pub did: String, 30 + }
+31
crates/webscrobbler/src/xata/track.rs
···
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, sqlx::FromRow, Serialize, Deserialize, Clone)] 5 + pub struct Track { 6 + pub xata_id: String, 7 + pub title: String, 8 + pub artist: String, 9 + pub album_artist: String, 10 + pub album_art: Option<String>, 11 + pub album: String, 12 + pub track_number: i32, 13 + pub duration: i32, 14 + pub mb_id: Option<String>, 15 + pub youtube_link: Option<String>, 16 + pub spotify_link: Option<String>, 17 + pub tidal_link: Option<String>, 18 + pub apple_music_link: Option<String>, 19 + pub sha256: String, 20 + pub lyrics: Option<String>, 21 + pub composer: Option<String>, 22 + pub genre: Option<String>, 23 + pub disc_number: i32, 24 + pub copyright_message: Option<String>, 25 + pub label: Option<String>, 26 + pub uri: Option<String>, 27 + pub artist_uri: Option<String>, 28 + pub album_uri: Option<String>, 29 + #[serde(with = "chrono::serde::ts_seconds")] 30 + pub xata_createdat: DateTime<Utc>, 31 + }
+14
crates/webscrobbler/src/xata/user.rs
···
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 + pub struct User { 6 + pub xata_id: String, 7 + pub display_name: String, 8 + pub did: String, 9 + pub handle: String, 10 + pub avatar: String, 11 + pub shared_secret: Option<String>, 12 + #[serde(with = "chrono::serde::ts_seconds")] 13 + pub xata_createdat: DateTime<Utc>, 14 + }
+14
crates/webscrobbler/src/xata/webscrobbler.rs
···
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 + pub struct Webscrobbler { 6 + pub xata_id: String, 7 + pub name: String, 8 + pub description: Option<String>, 9 + pub user_id: String, 10 + pub uuid: String, 11 + pub enabled: bool, 12 + #[serde(with = "chrono::serde::ts_seconds")] 13 + pub xata_createdat: DateTime<Utc>, 14 + }