diff --git a/Cargo.lock b/Cargo.lock index de0ae0a0..c3f0952a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5677,20 +5677,23 @@ name = "webscrobbler" version = "0.1.0" dependencies = [ "actix-web", + "aes", "anyhow", "chrono", + "ctr", "dotenv", "hex", "jsonwebtoken", "md5", "owo-colors", - "quick-xml 0.37.4", + "rand 0.9.0", "redis", "reqwest", "serde", "serde_json", "sqlx", "tokio", + "tokio-stream", ] [[package]] diff --git a/crates/webscrobbler/Cargo.toml b/crates/webscrobbler/Cargo.toml index 0827d15d..55eaa6d5 100644 --- a/crates/webscrobbler/Cargo.toml +++ b/crates/webscrobbler/Cargo.toml @@ -35,5 +35,8 @@ reqwest = { version = "0.12.12", features = [ "json", "multipart", ], default-features = false } -quick-xml = { version = "0.37.4", features = ["serialize"] } chrono = { version = "= 0.4.39", features = ["serde"] } +aes = "0.8.4" +ctr = "0.9.2" +rand = "0.9.0" +tokio-stream = { version = "0.1.17", features = ["full"] } diff --git a/crates/webscrobbler/src/auth.rs b/crates/webscrobbler/src/auth.rs new file mode 100644 index 00000000..48460c67 --- /dev/null +++ b/crates/webscrobbler/src/auth.rs @@ -0,0 +1,63 @@ +use anyhow::Error; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::EncodingKey; +use jsonwebtoken::Header; +use jsonwebtoken::Validation; +use serde::{Deserialize, Serialize}; +use std::env; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + exp: usize, + iat: usize, + did: String, +} + +pub fn generate_token(did: &str) -> Result { + if env::var("JWT_SECRET").is_err() { + return Err(Error::msg("JWT_SECRET is not set")); + } + + let claims = Claims { + exp: chrono::Utc::now().timestamp() as usize + 3600, + iat: chrono::Utc::now().timestamp() as usize, + did: did.to_string(), + }; + + jsonwebtoken::encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(env::var("JWT_SECRET")?.as_ref()), + ) + .map_err(Into::into) +} + +pub fn decode_token(token: &str) -> Result { + if env::var("JWT_SECRET").is_err() { + return Err(Error::msg("JWT_SECRET is not set")); + } + + jsonwebtoken::decode::( + token, + &DecodingKey::from_secret(env::var("JWT_SECRET")?.as_ref()), + &Validation::default(), + ) + .map(|data| data.claims) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use dotenv::dotenv; + + use super::*; + + #[test] + fn test_generate_token() { + dotenv().ok(); + let token = generate_token("did:plc:7vdlgi2bflelz7mmuxoqjfcr").unwrap(); + let claims = decode_token(&token).unwrap(); + + assert_eq!(claims.did, "did:plc:7vdlgi2bflelz7mmuxoqjfcr"); + } +} diff --git a/crates/webscrobbler/src/crypto.rs b/crates/webscrobbler/src/crypto.rs new file mode 100644 index 00000000..52650605 --- /dev/null +++ b/crates/webscrobbler/src/crypto.rs @@ -0,0 +1,22 @@ +use std::env; + +use aes::{ + cipher::{KeyIvInit, StreamCipher}, + Aes256, +}; +use anyhow::Error; +use hex::decode; + +type Aes256Ctr = ctr::Ctr64BE; + +pub fn decrypt_aes_256_ctr(encrypted_text: &str, key: &[u8]) -> Result { + let iv = decode(env::var("SPOTIFY_ENCRYPTION_IV")?)?; + let ciphertext = decode(encrypted_text)?; + + let mut cipher = + Aes256Ctr::new_from_slices(key, &iv).map_err(|_| Error::msg("Invalid key or IV"))?; + let mut decrypted_data = ciphertext.clone(); + cipher.apply_keystream(&mut decrypted_data); + + Ok(String::from_utf8(decrypted_data)?) +} diff --git a/crates/webscrobbler/src/handlers.rs b/crates/webscrobbler/src/handlers.rs index fcbe1ba4..cfb3039a 100644 --- a/crates/webscrobbler/src/handlers.rs +++ b/crates/webscrobbler/src/handlers.rs @@ -1,9 +1,60 @@ -use actix_web::{get, HttpResponse, Responder}; - -use crate::BANNER; +use std::sync::Arc; +use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder}; +use owo_colors::OwoColorize; +use sqlx::{Pool, Postgres}; +use crate::{cache::Cache, repo, scrobbler::scrobble, types::ScrobbleRequest, BANNER}; +use tokio_stream::StreamExt; +#[macro_export] +macro_rules! read_payload { + ($payload:expr) => {{ + let mut body = Vec::new(); + while let Some(chunk) = $payload.next().await { + match chunk { + Ok(bytes) => body.extend_from_slice(&bytes), + Err(err) => return Err(err.into()), + } + } + body + }}; +} #[get("/")] pub async fn index() -> impl Responder { HttpResponse::Ok().body(BANNER) -} \ No newline at end of file +} + +#[post("/{id}")] +async fn handle_scrobble( + data: web::Data>>, + cache: web::Data, + mut payload: web::Payload, + req: HttpRequest, +) -> Result { + let id = req.match_info().get("id").unwrap(); + println!("Received scrobble for ID: {}", id.cyan()); + + let pool = data.get_ref().clone(); + + let user = repo::user::get_user_by_webscrobbler(&pool, id).await + .map_err(|err| actix_web::error::ErrorInternalServerError(format!("Database error: {}", err)))?; + + if user.is_none() { + return Ok(HttpResponse::NotFound().body("There is no user with this webscrobbler ID")); + } + let user = user.unwrap(); + + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body) + .map_err(|err| actix_web::error::ErrorBadRequest(format!("Failed to parse JSON: {}", err)))?; + + println!("Parsed scrobble request: {:#?}", params); + + let cache = cache.get_ref().clone(); + + scrobble(&pool, &cache, params, &user.xata_id).await + .map_err(|err| actix_web::error::ErrorInternalServerError(format!("Failed to scrobble: {}", err)))?; + + + Ok(HttpResponse::Ok().body("Scrobble received")) +} diff --git a/crates/webscrobbler/src/main.rs b/crates/webscrobbler/src/main.rs index b4a56432..b9090ce1 100644 --- a/crates/webscrobbler/src/main.rs +++ b/crates/webscrobbler/src/main.rs @@ -7,8 +7,17 @@ use dotenv::dotenv; use owo_colors::OwoColorize; use sqlx::postgres::PgPoolOptions; +pub mod rocksky; pub mod cache; pub mod handlers; +pub mod xata; +pub mod types; +pub mod repo; +pub mod auth; +pub mod spotify; +pub mod musicbrainz; +pub mod scrobbler; +pub mod crypto; pub const BANNER: &str = r#" _ __ __ _____ __ __ __ @@ -29,8 +38,6 @@ async fn main() -> Result<(), Error> { let cache = Cache::new()?; - - let pool = PgPoolOptions::new() .max_connections(5) .connect(&env::var("XATA_POSTGRES_URL")?) @@ -38,7 +45,6 @@ async fn main() -> Result<(), Error> { let conn = Arc::new(pool); - let host = env::var("WEBSCROBBLER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let port = env::var("WEBSCROBBLER_PORT") .unwrap_or_else(|_| "7883".to_string()) @@ -46,7 +52,7 @@ async fn main() -> Result<(), Error> { .unwrap_or(7883); println!( - "Starting WebScrobbler WebHook @ {}", + "Starting WebScrobbler Webhook @ {}", format!("{}:{}", host, port).green() ); @@ -55,6 +61,7 @@ async fn main() -> Result<(), Error> { .app_data(Data::new(conn.clone())) .app_data(Data::new(cache.clone())) .service(handlers::index) + .service(handlers::handle_scrobble) }) .bind((host, port))? .run() diff --git a/crates/webscrobbler/src/musicbrainz/artist.rs b/crates/webscrobbler/src/musicbrainz/artist.rs new file mode 100644 index 00000000..ed759b32 --- /dev/null +++ b/crates/webscrobbler/src/musicbrainz/artist.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Clone)] +pub struct Artist { + pub name: String, + #[serde(rename = "sort-name")] + pub sort_name: String, + pub r#type: Option, + #[serde(rename = "type-id")] + pub type_id: Option, + #[serde(rename = "life-span")] + pub life_span: Option, + pub isnis: Option>, + pub ipis: Option>, + pub id: String, + #[serde(rename = "gender-id")] + pub gender_id: Option, + pub gender: Option, + #[serde(rename = "end_area")] + pub end_area: Option, + #[serde(rename = "end-area")] + pub end_area_: Option, + pub disambiguation: Option, + pub country: Option, + pub begin_area: Option, + #[serde(rename = "begin-area")] + pub begin_area_: Option, + pub area: Option, + pub aliases: Option>, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ArtistCredit { + pub joinphrase: Option, + pub name: String, + pub artist: Artist, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Alias { + pub name: String, + #[serde(rename = "sort-name")] + pub sort_name: String, + pub locale: Option, + pub primary: Option, + pub r#type: Option, + #[serde(rename = "type-id")] + pub type_id: Option, + pub begin: Option, + pub end: Option, + pub ended: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Area { + pub disambiguation: Option, + pub id: String, + pub name: String, + #[serde(rename = "sort-name")] + pub sort_name: String, + pub r#type: Option, + #[serde(rename = "type-id")] + pub type_id: Option, + #[serde(rename = "iso-3166-1-codes")] + pub iso_3166_1_codes: Option>, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LifeSpan { + pub begin: Option, + pub end: Option, + pub ended: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Params { + pub inc: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Artists { + pub created: String, + pub count: u32, + pub offset: u32, + pub artists: Vec, +} diff --git a/crates/webscrobbler/src/musicbrainz/client.rs b/crates/webscrobbler/src/musicbrainz/client.rs new file mode 100644 index 00000000..5b9fdf19 --- /dev/null +++ b/crates/webscrobbler/src/musicbrainz/client.rs @@ -0,0 +1,56 @@ +use super::recording::{Recording, Recordings}; +use anyhow::Error; + +pub const BASE_URL: &str = "https://musicbrainz.org/ws/2"; +pub const USER_AGENT: &str = "Rocksky/0.1.0"; + +pub struct MusicbrainzClient {} + +impl MusicbrainzClient { + pub fn new() -> Self { + MusicbrainzClient {} + } + + pub async fn search( + &self, + query: &str, + ) -> Result { + let url = format!("{}/recording", BASE_URL); + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .query( + &[ + ("query", query), + ("inc", "artist-credits+releases"), + ], + ) + .send() + .await?; + + Ok(response.json().await?) + } + + pub async fn get_recording( + &self, + mbid: &str, + ) -> Result { + let url = format!("{}/recording/{}", BASE_URL, mbid); + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .query( + &[ + ("inc", "artist-credits+releases"), + ], + ) + .send() + .await?; + + Ok(response.json().await?) + } +} diff --git a/crates/webscrobbler/src/musicbrainz/label.rs b/crates/webscrobbler/src/musicbrainz/label.rs new file mode 100644 index 00000000..547ee838 --- /dev/null +++ b/crates/webscrobbler/src/musicbrainz/label.rs @@ -0,0 +1,30 @@ +use serde::Deserialize; + +use super::artist::{Area, LifeSpan}; + +#[derive(Debug, Deserialize, Clone)] +pub struct Label { + #[serde(rename = "type-id")] + pub type_id: String, + pub disambiguation: String, + #[serde(rename = "label-code")] + pub label_code: u32, + #[serde(rename = "sort-name")] + pub sort_name: String, + pub id: String, + pub name: String, + pub r#type: String, + pub area: Option, + pub country: Option, + pub isnis: Option>, + pub ipis: Option>, + #[serde(rename = "life-span")] + pub life_span: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LabelInfo { + #[serde(rename = "catalog-number")] + pub catalog_number: String, + pub label: Label, +} diff --git a/crates/webscrobbler/src/musicbrainz/mod.rs b/crates/webscrobbler/src/musicbrainz/mod.rs new file mode 100644 index 00000000..acceccd2 --- /dev/null +++ b/crates/webscrobbler/src/musicbrainz/mod.rs @@ -0,0 +1,5 @@ +pub mod artist; +pub mod client; +pub mod label; +pub mod recording; +pub mod release; diff --git a/crates/webscrobbler/src/musicbrainz/recording.rs b/crates/webscrobbler/src/musicbrainz/recording.rs new file mode 100644 index 00000000..9271d5b1 --- /dev/null +++ b/crates/webscrobbler/src/musicbrainz/recording.rs @@ -0,0 +1,25 @@ +use serde::Deserialize; + +use super::{artist::ArtistCredit, release::Release}; + +#[derive(Debug, Deserialize)] +pub struct Recordings { + pub recordings: Vec, + pub count: u32, + pub offset: u32, + pub created: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Recording { + #[serde(rename = "first-release-date")] + pub first_release_date: Option, + pub title: String, + pub disambiguation: Option, + pub video: Option, + #[serde(rename = "artist-credit")] + pub artist_credit: Option>, + pub id: String, + pub length: Option, + pub releases: Option>, +} diff --git a/crates/webscrobbler/src/musicbrainz/release.rs b/crates/webscrobbler/src/musicbrainz/release.rs new file mode 100644 index 00000000..51e50068 --- /dev/null +++ b/crates/webscrobbler/src/musicbrainz/release.rs @@ -0,0 +1,92 @@ +use serde::Deserialize; + +use super::{ + artist::{Area, ArtistCredit}, + label::LabelInfo, + recording::Recording, +}; + +#[derive(Debug, Deserialize, Clone)] +pub struct Release { + #[serde(rename = "release-events")] + pub release_events: Option>, + pub quality: Option, + #[serde(rename = "text-representation")] + pub text_representation: Option, + pub status: Option, + pub packaging: Option, + pub barcode: Option, + pub id: String, + #[serde(rename = "packaging-id")] + pub packaging_id: Option, + pub media: Option>, + pub disambiguation: Option, + #[serde(rename = "cover-art-archive")] + pub cover_art_archive: Option, + #[serde(rename = "artist-credit")] + pub artist_credit: Vec, + #[serde(rename = "status-id")] + pub status_id: Option, + #[serde(rename = "label-info")] + pub label_info: Option>, + pub title: String, + pub date: Option, + pub country: Option, + pub asin: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct CoverArtArchive { + pub back: bool, + pub artwork: bool, + pub front: bool, + pub count: u32, + pub darkened: bool, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ReleaseEvent { + pub area: Option, + pub date: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct TextRepresentation { + pub language: Option, + pub script: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Media { + #[serde(rename = "format-id")] + pub format_id: Option, + pub discs: Option>, + pub position: u32, + pub tracks: Option>, + #[serde(rename = "track-offset")] + pub track_offset: u32, + pub title: Option, + #[serde(rename = "track-count")] + pub track_count: u32, + pub format: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Disc { + pub offset: Option, + pub sectors: u32, + pub id: String, + pub offsets: Option>, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Track { + pub length: i64, + pub id: String, + pub position: u32, + pub title: String, + pub recording: Recording, + #[serde(rename = "artist-credit")] + pub artist_credit: Vec, + pub number: String, +} diff --git a/crates/webscrobbler/src/repo/album.rs b/crates/webscrobbler/src/repo/album.rs new file mode 100644 index 00000000..dd99a446 --- /dev/null +++ b/crates/webscrobbler/src/repo/album.rs @@ -0,0 +1,17 @@ +use anyhow::Error; +use sqlx::{Pool, Postgres}; + +use crate::xata::album::Album; + +pub async fn get_album_by_track_id(pool: &Pool, track_id: &str) -> Result { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM albums + LEFT JOIN album_tracks ON albums.xata_id = album_tracks.album_id + WHERE album_tracks.track_id = $1 + "#) + .bind(track_id) + .fetch_all(pool) + .await?; + + Ok(results[0].clone()) +} \ No newline at end of file diff --git a/crates/webscrobbler/src/repo/artist.rs b/crates/webscrobbler/src/repo/artist.rs new file mode 100644 index 00000000..8f455fb4 --- /dev/null +++ b/crates/webscrobbler/src/repo/artist.rs @@ -0,0 +1,17 @@ +use anyhow::Error; +use sqlx::{Pool, Postgres}; + +use crate::xata::artist::Artist; + +pub async fn get_artist_by_track_id(pool: &Pool, track_id: &str) -> Result { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM artists + LEFT JOIN artist_tracks ON artists.xata_id = artist_tracks.artist_id + WHERE artist_tracks.track_id = $1 + "#) + .bind(track_id) + .fetch_all(pool) + .await?; + + Ok(results[0].clone()) +} \ No newline at end of file diff --git a/crates/webscrobbler/src/repo/mod.rs b/crates/webscrobbler/src/repo/mod.rs new file mode 100644 index 00000000..42b71632 --- /dev/null +++ b/crates/webscrobbler/src/repo/mod.rs @@ -0,0 +1,7 @@ +pub mod album; +pub mod artist; +pub mod spotify_account; +pub mod spotify_token; +pub mod track; +pub mod user; +pub mod webscrobbler; diff --git a/crates/webscrobbler/src/repo/spotify_account.rs b/crates/webscrobbler/src/repo/spotify_account.rs new file mode 100644 index 00000000..a8631d7d --- /dev/null +++ b/crates/webscrobbler/src/repo/spotify_account.rs @@ -0,0 +1,19 @@ +use sqlx::{Pool, Postgres}; +use anyhow::Error; +use crate::xata::spotify_account::SpotifyAccount; + +pub async fn get_spotify_account(pool: &Pool, user_id: &str) -> Result, Error> { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM spotify_accounts + WHERE user_id = $1 + "#) + .bind(user_id) + .fetch_all(pool) + .await?; + + if results.len() == 0 { + return Ok(None); + } + + Ok(Some(results[0].clone())) +} diff --git a/crates/webscrobbler/src/repo/spotify_token.rs b/crates/webscrobbler/src/repo/spotify_token.rs new file mode 100644 index 00000000..ca0a7505 --- /dev/null +++ b/crates/webscrobbler/src/repo/spotify_token.rs @@ -0,0 +1,37 @@ +use anyhow::Error; +use sqlx::{Pool, Postgres}; + +use crate::xata::spotify_token::SpotifyToken; + + +pub async fn get_spotify_token(pool: &Pool, did: &str) -> Result, Error> { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM spotify_tokens + LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id + LEFT JOIN users ON spotify_accounts.user_id = users.xata_id + WHERE users.did = $1 + "#) + .bind(did) + .fetch_all(pool) + .await?; + + if results.len() == 0 { + return Ok(None); + } + + Ok(Some(results[0].clone())) +} + +pub async fn get_spotify_tokens(pool: &Pool, limit: u32) -> Result, Error> { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM spotify_tokens + LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id + LEFT JOIN users ON spotify_accounts.user_id = users.xata_id + LIMIT $1 + "#) + .bind(limit as i32) + .fetch_all(pool) + .await?; + + Ok(results) +} \ No newline at end of file diff --git a/crates/webscrobbler/src/repo/track.rs b/crates/webscrobbler/src/repo/track.rs new file mode 100644 index 00000000..abdb7c36 --- /dev/null +++ b/crates/webscrobbler/src/repo/track.rs @@ -0,0 +1,37 @@ +use anyhow::Error; +use sqlx::{Pool, Postgres}; + +use crate::xata::track::Track; + +pub async fn get_track(pool: &Pool, title: &str, artist: &str) -> Result, Error> { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM tracks + WHERE LOWER(title) = LOWER($1) + AND (LOWER(artist) = LOWER($2) OR LOWER(album_artist) = LOWER($2)) + "#) + .bind(title) + .bind(artist) + .fetch_all(pool) + .await?; + + if results.len() == 0 { + return Ok(None); + } + + Ok(Some(results[0].clone())) +} + +pub async fn get_track_by_mbid(pool: &Pool, mbid: &str) -> Result, Error> { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM tracks WHERE mb_id = $1 + "#) + .bind(mbid) + .fetch_all(pool) + .await?; + + if results.len() == 0 { + return Ok(None); + } + + Ok(Some(results[0].clone())) +} \ No newline at end of file diff --git a/crates/webscrobbler/src/repo/user.rs b/crates/webscrobbler/src/repo/user.rs new file mode 100644 index 00000000..28ec8618 --- /dev/null +++ b/crates/webscrobbler/src/repo/user.rs @@ -0,0 +1,22 @@ +use anyhow::Error; +use sqlx::{Pool, Postgres}; + +use crate::xata::user::User; + + +pub async fn get_user_by_webscrobbler(pool: &Pool, uuid: &str) -> Result, Error> { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM users + LEFT JOIN webscrobblers ON users.xata_id = webscrobblers.user_id + WHERE webscrobblers.uuid = $1 + "#) + .bind(uuid) + .fetch_all(pool) + .await?; + + if results.len() == 0 { + return Ok(None); + } + + Ok(Some(results[0].clone())) +} diff --git a/crates/webscrobbler/src/repo/webscrobbler.rs b/crates/webscrobbler/src/repo/webscrobbler.rs new file mode 100644 index 00000000..ef321f82 --- /dev/null +++ b/crates/webscrobbler/src/repo/webscrobbler.rs @@ -0,0 +1,19 @@ +use anyhow::Error; +use sqlx::{Pool, Postgres}; +use crate::xata::webscrobbler::Webscrobbler; + +pub async fn get_webscrobbler(pool: &Pool, uuid: &str) -> Result, Error> { + let results: Vec = sqlx::query_as(r#" + SELECT * FROM webscrobblers + WHERE uuid = $1 + "#) + .bind(uuid) + .fetch_all(pool) + .await?; + + if results.len() == 0 { + return Ok(None); + } + + Ok(Some(results[0].clone())) +} diff --git a/crates/webscrobbler/src/rocksky.rs b/crates/webscrobbler/src/rocksky.rs new file mode 100644 index 00000000..bddcfcca --- /dev/null +++ b/crates/webscrobbler/src/rocksky.rs @@ -0,0 +1,38 @@ +use anyhow::Error; +use reqwest::Client; + +use crate::{auth::generate_token, cache::Cache, types::Track}; + +const ROCKSKY_API: &str = "https://api.rocksky.app"; + +pub async fn scrobble(cache: &Cache, did: &str, track: Track, timestamp: u64) -> Result<(), Error> { + let key = format!("{} - {}", track.artist.to_lowercase(), track.title.to_lowercase()); + + // Check if the track is already in the cache, if not add it + if !cache.exists(&key)? { + let value = serde_json::to_string(&track)?; + let ttl = 15 * 60; // 15 minutes + cache.setex(&key, &value, ttl)?; + } + + let mut track = track; + track.timestamp = Some(timestamp / 1000 as u64); + + let token = generate_token(did)?; + let client = Client::new(); + + println!("Scrobbling track: \n {:#?}", track); + + let response= client + .post(&format!("{}/now-playing", ROCKSKY_API)) + .bearer_auth(token) + .json(&track) + .send() + .await?; + + if !response.status().is_success() { + return Err(Error::msg(format!("Failed to scrobble track: {}", response.text().await?))); + } + + Ok(()) +} diff --git a/crates/webscrobbler/src/scrobbler.rs b/crates/webscrobbler/src/scrobbler.rs new file mode 100644 index 00000000..1bd35456 --- /dev/null +++ b/crates/webscrobbler/src/scrobbler.rs @@ -0,0 +1,113 @@ +use std::env; + +use owo_colors::OwoColorize; +use rand::Rng; +use sqlx::{Pool, Postgres}; +use anyhow::Error; +use crate::cache::Cache; +use crate::crypto::decrypt_aes_256_ctr; +use crate::musicbrainz::client::MusicbrainzClient; +use crate::spotify::client::SpotifyClient; +use crate::spotify::refresh_token; +use crate::{repo, rocksky}; +use crate::types::{ScrobbleRequest, Track}; + +pub async fn scrobble(pool: &Pool, cache: &Cache, scrobble: ScrobbleRequest, did: &str) -> Result<(), Error> { + let spofity_tokens = repo::spotify_token::get_spotify_tokens(pool, 100).await?; + + if spofity_tokens.is_empty() { + return Err(Error::msg("No Spotify tokens found")); + } + + let mb_client = MusicbrainzClient::new(); + + let key = format!("{} - {}", scrobble.data.song.parsed.artist.to_lowercase(), scrobble.data.song.parsed.track.to_lowercase()); + + let cached = cache.get(&key)?; + if cached.is_some() { + println!("{}", format!("Cached: {}", key).yellow()); + let track = serde_json::from_str::(&cached.unwrap())?; + rocksky::scrobble(cache, &did, track, scrobble.time).await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } + + let result = repo::track::get_track(pool, &scrobble.data.song.parsed.track, &scrobble.data.song.parsed.artist).await?; + + if let Some(track) = result { + println!("{}", "Xata (track)".yellow()); + let album = repo::album::get_album_by_track_id(pool, &track.xata_id).await?; + let artist = repo::artist::get_artist_by_track_id(pool, &track.xata_id).await?; + let mut track: Track = track.into(); + track.year = match album.year { + Some(year) => Some(year as u32), + None => match album.release_date.clone() { + Some(release_date) => { + let year = release_date.split("-").next(); + year.and_then(|x| x.parse::().ok()) + } + None => None, + }, + }; + track.release_date = album.release_date.map(|x| x.split("T").next().unwrap().to_string()); + track.artist_picture = artist.picture.clone(); + + rocksky::scrobble(cache, &did, track, scrobble.time).await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } + + // we need to pick a random token to avoid Spotify rate limiting + // and to avoid using the same token for all scrobbles + // this is a simple way to do it, but we can improve it later + // by using a more sophisticated algorithm + // or by using a token pool + let mut rng = rand::rng(); + let random_index = rng.random_range(0..spofity_tokens.len()); + let spotify_token = &spofity_tokens[random_index]; + + let spotify_token = decrypt_aes_256_ctr( + &spotify_token.refresh_token, + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? + )?; + + let spotify_token = refresh_token(&spotify_token).await?; + let spotify_client = SpotifyClient::new(&spotify_token.access_token); + + let result = spotify_client.search(&format!(r#"track:"{}" artist:"{}""#, scrobble.data.song.parsed.track, scrobble.data.song.parsed.artist)).await?; + + if let Some(track) = result.tracks.items.first() { + println!("{}", "Spotify (track)".yellow()); + let mut track = track.clone(); + + if let Some(album) = spotify_client.get_album(&track.album.id).await? { + track.album = album; + } + + if let Some(artist) = spotify_client.get_artist(&track.album.artists[0].id).await? { + track.album.artists[0] = artist; + } + + rocksky::scrobble(cache, &did, track.into(), scrobble.time).await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } + + let query = format!( + r#"recording:"{}" AND artist:"{}""#, + scrobble.data.song.parsed.track, scrobble.data.song.parsed.artist + ); + let result = mb_client.search(&query).await?; + + if let Some(recording) = result.recordings.first() { + let result = mb_client.get_recording(&recording.id).await?; + println!("{}", "Musicbrainz (recording)".yellow()); + rocksky::scrobble(cache, &did, result.into(), scrobble.time).await?; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Ok(()); + } + + println!("{} {} - {}, skipping", "Track not found: ".yellow(), scrobble.data.song.parsed.artist, scrobble.data.song.parsed.track); + + Ok(()) +} \ No newline at end of file diff --git a/crates/webscrobbler/src/spotify/client.rs b/crates/webscrobbler/src/spotify/client.rs new file mode 100644 index 00000000..1bae6e15 --- /dev/null +++ b/crates/webscrobbler/src/spotify/client.rs @@ -0,0 +1,68 @@ +use super::types::{Album, Artist, SearchResponse}; +use anyhow::Error; + +pub const BASE_URL: &str = "https://api.spotify.com/v1"; + +pub struct SpotifyClient { + token: String, +} + +impl SpotifyClient { + pub fn new(token: &str) -> Self { + SpotifyClient { + token: token.to_string(), + } + } + + pub async fn search(&self, query: &str) -> Result { + let url = format!("{}/search", BASE_URL); + let client = reqwest::Client::new(); + let response = client.get(&url) + .bearer_auth(&self.token) + .query(&[ + ("type", "track"), + ("q", query), + ]) + .send().await?; + let result = response.json().await?; + Ok(result) + } + + pub async fn get_album(&self, id: &str) -> Result, Error> { + let url = format!("{}/albums/{}", BASE_URL, id); + let client = reqwest::Client::new(); + let response = client.get(&url) + .bearer_auth(&self.token) + .send().await?; + + let headers = response.headers().clone(); + let data = response.text().await?; + + if data == "Too many requests" { + println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); + println!("> {} [get_album]", data); + return Ok(None); + } + + Ok(Some(serde_json::from_str(&data)?)) + } + + pub async fn get_artist(&self, id: &str) -> Result, Error> { + let url = format!("{}/artists/{}", BASE_URL, id); + let client = reqwest::Client::new(); + let response = client.get(&url) + .bearer_auth(&self.token) + .send().await?; + + let headers = response.headers().clone(); + let data = response.text().await?; + + if data == "Too many requests" { + println!("> retry-after {}", headers.get("retry-after").unwrap().to_str().unwrap()); + println!("> {} [get_artist]", data); + return Ok(None); + } + + Ok(Some(serde_json::from_str(&data)?)) + } +} diff --git a/crates/webscrobbler/src/spotify/mod.rs b/crates/webscrobbler/src/spotify/mod.rs new file mode 100644 index 00000000..af1c17c6 --- /dev/null +++ b/crates/webscrobbler/src/spotify/mod.rs @@ -0,0 +1,32 @@ +use std::env; + +use reqwest::Client; +use types::AccessToken; +use anyhow::Error; + +pub mod client; +pub mod types; + + +pub async fn refresh_token(token: &str) -> Result { + if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { + panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); + } + + let client_id = env::var("SPOTIFY_CLIENT_ID")?; + let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; + + let client = Client::new(); + + let response = client.post("https://accounts.spotify.com/api/token") + .basic_auth(&client_id, Some(client_secret)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", token), + ("client_id", &client_id) + ]) + .send() + .await?; + let token = response.json::().await?; + Ok(token) +} \ No newline at end of file diff --git a/crates/webscrobbler/src/spotify/types.rs b/crates/webscrobbler/src/spotify/types.rs new file mode 100644 index 00000000..40e95f80 --- /dev/null +++ b/crates/webscrobbler/src/spotify/types.rs @@ -0,0 +1,104 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct SearchResponse { + pub tracks: Tracks, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Tracks { + pub href: String, + pub limit: u32, + pub next: Option, + pub offset: u32, + pub previous: Option, + pub total: u32, + pub items: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Track { + pub album: Album, + pub artists: Vec, + pub available_markets: Vec, + pub disc_number: u32, + pub duration_ms: u32, + pub explicit: bool, + pub external_ids: ExternalIds, + pub external_urls: ExternalUrls, + pub href: String, + pub id: String, + pub is_local: bool, + pub is_playable: Option, + pub name: String, + pub popularity: u32, + pub preview_url: Option, + pub track_number: u32, + #[serde(rename = "type")] + pub kind: String, + pub uri: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Album { + pub album_type: String, + pub artists: Vec, + pub available_markets: Vec, + pub external_urls: ExternalUrls, + pub href: String, + pub id: String, + pub images: Vec, + pub name: String, + pub release_date: String, + pub release_date_precision: String, + pub total_tracks: u32, + #[serde(rename = "type")] + pub album_type_field: String, + pub uri: String, + pub label: Option, + pub genres: Option>, + pub copyrights: Option>, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Copyright { + pub text: String, + pub r#type: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Artist { + pub external_urls: ExternalUrls, + pub href: String, + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub kind: String, + pub uri: String, + pub images: Option>, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ExternalUrls { + pub spotify: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ExternalIds { + pub isrc: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Image { + pub height: u32, + pub width: u32, + pub url: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct AccessToken { + pub access_token: String, + pub token_type: String, + pub scope: String, + pub expires_in: u32, +} diff --git a/crates/webscrobbler/src/types.rs b/crates/webscrobbler/src/types.rs new file mode 100644 index 00000000..7a51b349 --- /dev/null +++ b/crates/webscrobbler/src/types.rs @@ -0,0 +1,590 @@ +use serde::{Deserialize, Serialize}; + +use crate::{musicbrainz, spotify, xata}; + +#[derive(Deserialize, Debug, Clone)] +pub struct Connector { + pub id: String, + pub js: String, + pub label: String, + pub matches: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct IsRegrexEditedByUser { + pub album: bool, + pub album_artist: bool, + pub artist: bool, + pub track: bool, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Flags { + pub finished_processing: bool, + pub has_blocked_tag: bool, + pub is_album_fetched: bool, + pub is_corrected_by_user: bool, + pub is_loved_in_service: Option, + pub is_marked_as_playing: bool, + pub is_regex_edited_by_user: IsRegrexEditedByUser, + pub is_replaying: bool, + pub is_scrobbled: bool, + pub is_skipped: bool, + pub is_valid: bool, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + pub album_url: Option, + pub artist_url: Option, + pub label: String, + pub start_timestamp: u64, + pub track_url: Option, + pub user_play_count: u32, + pub userloved: bool, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NoRegex { + pub album: String, + pub album_artist: Option, + pub artist: String, + pub duration: Option, + pub track: String, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Parsed { + pub album: String, + pub album_artist: Option, + pub artist: String, + pub current_time: Option, + pub duration: u32, + pub is_playing: bool, + pub is_podcast: bool, + pub origin_url: Option, + pub scrobbling_disallowed_reason: Option, + pub track: String, + pub track_art: Option, + #[serde(rename = "uniqueID")] + pub unique_id: Option, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Song { + pub connector: Connector, + pub controller_tab_id: u64, + pub flags: Flags, + pub metadata: Metadata, + pub no_regex: NoRegex, + pub parsed: Parsed, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Processed { + pub album: String, + pub album_artist: Option, + pub artist: String, + pub duration: u32, + pub track: String, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Scrobble { + pub song: Song, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleRequest { + pub data: Scrobble, + pub event_name: String, + pub time: u64, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Track { + pub title: String, + pub album: String, + pub artist: String, + pub album_artist: Option, + pub duration: u32, + pub mbid: Option, + pub track_number: u32, + pub release_date: Option, + pub year: Option, + pub disc_number: u32, + pub album_art: Option, + pub spotify_link: Option, + pub label: Option, + pub artist_picture: Option, + pub timestamp: Option, +} + +impl From for Track { + fn from(track: xata::track::Track) -> Self { + Track { + title: track.title, + album: track.album, + artist: track.artist, + album_artist: Some(track.album_artist), + album_art: track.album_art, + spotify_link: track.spotify_link, + label: track.label, + artist_picture: None, + timestamp: None, + duration: track.duration as u32, + mbid: track.mb_id, + track_number: track.track_number as u32, + disc_number: track.disc_number as u32, + year: None, + release_date: None, + } + } +} + +impl From for Track { + fn from(recording: musicbrainz::recording::Recording) -> Self { + let artist_credit = recording + .artist_credit + .unwrap_or_default() + .first() + .map(|credit| credit.name.clone()) + .unwrap_or_default(); + let releases = recording.releases.unwrap_or_default(); + let album_artist = releases + .first() + .and_then(|release| release.artist_credit.first()) + .map(|credit| credit.name.clone()); + let album = releases + .first() + .map(|release| release.title.clone()) + .unwrap_or_default(); + Track { + title: recording.title.clone(), + album, + artist: artist_credit, + album_artist, + duration: recording.length.unwrap_or_default(), + year: recording + .first_release_date + .as_ref() + .and_then(|date| date.split('-').next()) + .and_then(|year| year.parse::().ok()), + release_date: recording.first_release_date.clone(), + track_number: releases + .first() + .and_then(|release| { + release + .media + .as_ref() + .and_then(|media| media.first()) + .and_then(|media| { + media + .tracks + .as_ref() + .and_then(|tracks| tracks.first()) + .map(|track| track.number.parse::().unwrap()) + }) + }) + .unwrap_or_default(), + disc_number: releases + .first() + .and_then(|release| { + release + .media + .as_ref() + .and_then(|media| media.first()) + .map(|media| media.position) + }) + .unwrap_or_default(), + ..Default::default() + } + } +} + +impl From<&spotify::types::Track> for Track { + fn from(track: &spotify::types::Track) -> Self { + Track { + title: track.name.clone(), + album: track.album.name.clone(), + artist: track + .artists + .iter() + .map(|artist| artist.name.clone()) + .collect::>() + .join(", "), + album_artist: track + .album + .artists + .first() + .map(|artist| artist.name.clone()), + duration: track.duration_ms as u32, + album_art: track.album.images.first().map(|image| image.url.clone()), + spotify_link: Some(track.external_urls.spotify.clone()), + artist_picture: track.album.artists.first().and_then(|artist| { + artist + .images + .as_ref() + .and_then(|images| images.first().map(|image| image.url.clone())) + }), + track_number: track.track_number, + disc_number: track.disc_number, + release_date: match track.album.release_date_precision.as_str() { + "day" => Some(track.album.release_date.clone()), + _ => None, + }, + year: match track.album.release_date_precision.as_str() { + "day" => Some( + track + .album + .release_date + .split('-') + .next() + .unwrap() + .parse::() + .unwrap(), + ), + "year" => Some(track.album.release_date.parse::().unwrap()), + _ => None, + }, + label: track.album.label.clone(), + ..Default::default() + } + } +} + +impl From for Track { + fn from(track: spotify::types::Track) -> Self { + Track::from(&track) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tidal_scrobble_request() { + let json = r#" + { + "data": { + "song": { + "connector": { + "id": "tidal", + "js": "tidal.js", + "label": "Tidal", + "matches": [ + "*://listen.tidalhifi.com/*", + "*://listen.tidal.com/*" + ] + }, + "controllerTabId": 2105806618, + "flags": { + "finishedProcessing": true, + "hasBlockedTag": false, + "isAlbumFetched": false, + "isCorrectedByUser": false, + "isLovedInService": null, + "isMarkedAsPlaying": true, + "isRegexEditedByUser": { + "album": false, + "albumArtist": false, + "artist": false, + "track": false + }, + "isReplaying": false, + "isScrobbled": false, + "isSkipped": false, + "isValid": true + }, + "metadata": { + "albumUrl": "https://www.last.fm/music/Tee+Grizzley/Forever+My+Moment+%5BClean%5D+%5BClean%5D", + "artistUrl": "https://www.last.fm/music/Tee+Grizzley", + "label": "Tidal", + "startTimestamp": 1747766980, + "trackUrl": "https://www.last.fm/music/Tee+Grizzley/_/Forever+My+Moment", + "userPlayCount": 0, + "userloved": false + }, + "noRegex": { + "album": "FOREVER MY MOMENT", + "albumArtist": null, + "artist": "Tee Grizzley", + "duration": null, + "track": "Forever My Moment" + }, + "parsed": { + "album": "FOREVER MY MOMENT", + "albumArtist": null, + "artist": "Tee Grizzley", + "currentTime": 17, + "duration": 182, + "isPlaying": false, + "isPodcast": false, + "originUrl": "https://listen.tidal.com/", + "scrobblingDisallowedReason": null, + "track": "Forever My Moment", + "trackArt": "https://resources.tidal.com/images/275251bf/9f03/46bf/9e46/3a3b0a67abe6/80x80.jpg", + "uniqueID": "434750253" + }, + "processed": { + "album": "FOREVER MY MOMENT", + "albumArtist": null, + "artist": "Tee Grizzley", + "duration": 182, + "track": "Forever My Moment" + } + } + }, + "eventName": "paused", + "time": 1747766997907 + } + "#; + + let result = serde_json::from_str::(json); + assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err()); + } + + #[test] + fn test_spotify_nowplaying_request() { + let json = r#" + { + "data": { + "song": { + "connector": { + "hasNativeScrobbler": true, + "id": "spotify", + "js": "spotify.js", + "label": "Spotify", + "matches": [ + "*://open.spotify.com/*" + ] + }, + "controllerTabId": 2105804433, + "flags": { + "finishedProcessing": true, + "hasBlockedTag": false, + "isAlbumFetched": false, + "isCorrectedByUser": false, + "isLovedInService": null, + "isMarkedAsPlaying": true, + "isRegexEditedByUser": { + "album": false, + "albumArtist": false, + "artist": false, + "track": false + }, + "isReplaying": false, + "isScrobbled": false, + "isSkipped": false, + "isValid": true + }, + "metadata": { + "albumUrl": "https://www.last.fm/music/The+Weeknd/Hurry+Up+Tomorrow+(First+Press)", + "artistUrl": "https://www.last.fm/music/The+Weeknd", + "label": "Spotify", + "startTimestamp": 1747753805, + "trackArtUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/eadb0529b2c5066ebe7f53c52e329def.png", + "trackUrl": "https://www.last.fm/music/The+Weeknd/_/Given+Up+on+Me", + "userPlayCount": 0, + "userloved": false + }, + "noRegex": { + "album": "Hurry Up Tomorrow", + "albumArtist": null, + "artist": "The Weeknd", + "duration": null, + "track": "Given Up On Me" + }, + "parsed": { + "album": "Hurry Up Tomorrow", + "albumArtist": null, + "artist": "The Weeknd", + "currentTime": null, + "duration": 354, + "isPlaying": true, + "isPodcast": false, + "originUrl": null, + "scrobblingDisallowedReason": null, + "track": "Given Up On Me", + "trackArt": "https://i.scdn.co/image/ab67616d00001e02982320da137d0de34410df61", + "uniqueID": null + }, + "processed": { + "album": "Hurry Up Tomorrow", + "albumArtist": null, + "artist": "The Weeknd", + "duration": 354, + "track": "Given Up on Me" + } + } + }, + "eventName": "nowplaying", + "time": 1747753806195 + } + "#; + + let result = serde_json::from_str::(json); + assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err()); + } + + #[test] + fn test_spotify_scrobble_request() { + let json = r#" + { + "data": { + "currentlyPlaying": true, + "song": { + "connector": { + "hasNativeScrobbler": true, + "id": "spotify", + "js": "spotify.js", + "label": "Spotify", + "matches": [ + "*://open.spotify.com/*" + ] + }, + "controllerTabId": 2105804433, + "flags": { + "finishedProcessing": true, + "hasBlockedTag": false, + "isAlbumFetched": false, + "isCorrectedByUser": false, + "isLovedInService": null, + "isMarkedAsPlaying": true, + "isRegexEditedByUser": { + "album": false, + "albumArtist": false, + "artist": false, + "track": false + }, + "isReplaying": false, + "isScrobbled": false, + "isSkipped": false, + "isValid": true + }, + "metadata": { + "artistUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory", + "label": "Spotify", + "startTimestamp": 1747753624, + "trackUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory/_/Never+Let+Me+Down", + "userPlayCount": 0, + "userloved": false + }, + "noRegex": { + "album": "Never Let Me Down", + "albumArtist": null, + "artist": "VIZE, Tom Gregory", + "duration": null, + "track": "Never Let Me Down" + }, + "parsed": { + "album": "Never Let Me Down", + "albumArtist": null, + "artist": "VIZE, Tom Gregory", + "currentTime": 76, + "duration": 153, + "isPlaying": true, + "isPodcast": false, + "originUrl": null, + "scrobblingDisallowedReason": null, + "track": "Never Let Me Down", + "trackArt": "https://i.scdn.co/image/ab67616d00001e02e33c4ba1bf5eecbbc7dddc85", + "uniqueID": null + }, + "processed": { + "album": "Never Let Me Down", + "albumArtist": null, + "artist": "VIZE, Tom Gregory", + "duration": 153, + "track": "Never Let Me Down" + } + }, + "songs": [ + { + "connector": { + "hasNativeScrobbler": true, + "id": "spotify", + "js": "spotify.js", + "label": "Spotify", + "matches": [ + "*://open.spotify.com/*" + ] + }, + "controllerTabId": 2105804433, + "flags": { + "finishedProcessing": true, + "hasBlockedTag": false, + "isAlbumFetched": false, + "isCorrectedByUser": false, + "isLovedInService": null, + "isMarkedAsPlaying": true, + "isRegexEditedByUser": { + "album": false, + "albumArtist": false, + "artist": false, + "track": false + }, + "isReplaying": false, + "isScrobbled": false, + "isSkipped": false, + "isValid": true + }, + "metadata": { + "artistUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory", + "label": "Spotify", + "startTimestamp": 1747753624, + "trackUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory/_/Never+Let+Me+Down", + "userPlayCount": 0, + "userloved": false + }, + "noRegex": { + "album": "Never Let Me Down", + "albumArtist": null, + "artist": "VIZE, Tom Gregory", + "duration": null, + "track": "Never Let Me Down" + }, + "parsed": { + "album": "Never Let Me Down", + "albumArtist": null, + "artist": "VIZE, Tom Gregory", + "currentTime": 76, + "duration": 153, + "isPlaying": true, + "isPodcast": false, + "originUrl": null, + "scrobblingDisallowedReason": null, + "track": "Never Let Me Down", + "trackArt": "https://i.scdn.co/image/ab67616d00001e02e33c4ba1bf5eecbbc7dddc85", + "uniqueID": null + }, + "processed": { + "album": "Never Let Me Down", + "albumArtist": null, + "artist": "VIZE, Tom Gregory", + "duration": 153, + "track": "Never Let Me Down" + } + } + ] + }, + "eventName": "scrobble", + "time": 1747753702338 + } + "#; + + let result = serde_json::from_str::(json); + assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err()); + } +} diff --git a/crates/webscrobbler/src/xata/album.rs b/crates/webscrobbler/src/xata/album.rs new file mode 100644 index 00000000..fca2adf2 --- /dev/null +++ b/crates/webscrobbler/src/xata/album.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +#[derive(Debug, sqlx::FromRow, Deserialize, Clone)] +pub struct Album { + pub xata_id: String, + pub title: String, + pub artist: String, + pub release_date: Option, + pub album_art: Option, + pub year: Option, + pub spotify_link: Option, + pub tidal_link: Option, + pub youtube_link: Option, + pub apple_music_link: Option, + pub sha256: String, + pub uri: Option, + pub artist_uri: Option, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_createdat: DateTime, +} diff --git a/crates/webscrobbler/src/xata/artist.rs b/crates/webscrobbler/src/xata/artist.rs new file mode 100644 index 00000000..25187ec5 --- /dev/null +++ b/crates/webscrobbler/src/xata/artist.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +#[derive(Debug, sqlx::FromRow, Deserialize, Clone)] +pub struct Artist { + pub xata_id: String, + pub name: String, + pub biography: Option, + #[serde(with = "chrono::serde::ts_seconds_option")] + pub born: Option>, + pub born_in: Option, + #[serde(with = "chrono::serde::ts_seconds_option")] + pub died: Option>, + pub picture: Option, + pub sha256: String, + pub spotify_link: Option, + pub tidal_link: Option, + pub youtube_link: Option, + pub apple_music_link: Option, + pub uri: Option, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_createdat: DateTime, +} diff --git a/crates/webscrobbler/src/xata/mod.rs b/crates/webscrobbler/src/xata/mod.rs new file mode 100644 index 00000000..42b71632 --- /dev/null +++ b/crates/webscrobbler/src/xata/mod.rs @@ -0,0 +1,7 @@ +pub mod album; +pub mod artist; +pub mod spotify_account; +pub mod spotify_token; +pub mod track; +pub mod user; +pub mod webscrobbler; diff --git a/crates/webscrobbler/src/xata/spotify_account.rs b/crates/webscrobbler/src/xata/spotify_account.rs new file mode 100644 index 00000000..74a42847 --- /dev/null +++ b/crates/webscrobbler/src/xata/spotify_account.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +#[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] +pub struct SpotifyAccount { + pub xata_id: String, + pub xata_version: i32, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_createdat: DateTime, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_updatedat: DateTime, + pub email: String, + pub user_id: String, + pub is_beta_user: bool, +} diff --git a/crates/webscrobbler/src/xata/spotify_token.rs b/crates/webscrobbler/src/xata/spotify_token.rs new file mode 100644 index 00000000..5f137a09 --- /dev/null +++ b/crates/webscrobbler/src/xata/spotify_token.rs @@ -0,0 +1,30 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +#[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] +pub struct SpotifyToken { + pub xata_id: String, + pub xata_version: i32, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_createdat: DateTime, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_updatedat: DateTime, + pub user_id: String, + pub access_token: String, + pub refresh_token: String, +} + +#[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] +pub struct SpotifyTokenWithEmail { + pub xata_id: String, + pub xata_version: i32, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_createdat: DateTime, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_updatedat: DateTime, + pub user_id: String, + pub access_token: String, + pub refresh_token: String, + pub email: String, + pub did: String, +} diff --git a/crates/webscrobbler/src/xata/track.rs b/crates/webscrobbler/src/xata/track.rs new file mode 100644 index 00000000..cc2b0015 --- /dev/null +++ b/crates/webscrobbler/src/xata/track.rs @@ -0,0 +1,31 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, Clone)] +pub struct Track { + pub xata_id: String, + pub title: String, + pub artist: String, + pub album_artist: String, + pub album_art: Option, + pub album: String, + pub track_number: i32, + pub duration: i32, + pub mb_id: Option, + pub youtube_link: Option, + pub spotify_link: Option, + pub tidal_link: Option, + pub apple_music_link: Option, + pub sha256: String, + pub lyrics: Option, + pub composer: Option, + pub genre: Option, + pub disc_number: i32, + pub copyright_message: Option, + pub label: Option, + pub uri: Option, + pub artist_uri: Option, + pub album_uri: Option, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_createdat: DateTime, +} diff --git a/crates/webscrobbler/src/xata/user.rs b/crates/webscrobbler/src/xata/user.rs new file mode 100644 index 00000000..6cc5cfc9 --- /dev/null +++ b/crates/webscrobbler/src/xata/user.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +#[derive(Debug, sqlx::FromRow, Deserialize, Clone)] +pub struct User { + pub xata_id: String, + pub display_name: String, + pub did: String, + pub handle: String, + pub avatar: String, + pub shared_secret: Option, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_createdat: DateTime, +} diff --git a/crates/webscrobbler/src/xata/webscrobbler.rs b/crates/webscrobbler/src/xata/webscrobbler.rs new file mode 100644 index 00000000..5e47fab6 --- /dev/null +++ b/crates/webscrobbler/src/xata/webscrobbler.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +#[derive(Debug, sqlx::FromRow, Deserialize, Clone)] +pub struct Webscrobbler { + pub xata_id: String, + pub name: String, + pub description: Option, + pub user_id: String, + pub uuid: String, + pub enabled: bool, + #[serde(with = "chrono::serde::ts_seconds")] + pub xata_createdat: DateTime, +}