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

add new 'last.fm' compatible scrobble API

+2204 -327
+79 -26
Cargo.lock
··· 49 49 "mime", 50 50 "percent-encoding", 51 51 "pin-project-lite", 52 - "rand", 52 + "rand 0.8.5", 53 53 "sha1", 54 54 "smallvec", 55 55 "tokio", ··· 233 233 "getrandom 0.2.15", 234 234 "once_cell", 235 235 "version_check", 236 - "zerocopy", 236 + "zerocopy 0.7.35", 237 237 ] 238 238 239 239 [[package]] ··· 557 557 "once_cell", 558 558 "pin-project", 559 559 "portable-atomic", 560 - "rand", 560 + "rand 0.8.5", 561 561 "regex", 562 562 "ring", 563 563 "rustls-native-certs", ··· 2470 2470 "ed25519-dalek", 2471 2471 "getrandom 0.2.15", 2472 2472 "log", 2473 - "rand", 2473 + "rand 0.8.5", 2474 2474 "signatory", 2475 2475 ] 2476 2476 ··· 2498 2498 source = "registry+https://github.com/rust-lang/crates.io-index" 2499 2499 checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" 2500 2500 dependencies = [ 2501 - "rand", 2501 + "rand 0.8.5", 2502 2502 ] 2503 2503 2504 2504 [[package]] ··· 2537 2537 "num-integer", 2538 2538 "num-iter", 2539 2539 "num-traits", 2540 - "rand", 2540 + "rand 0.8.5", 2541 2541 "smallvec", 2542 2542 "zeroize", 2543 2543 ] ··· 2729 2729 checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 2730 2730 dependencies = [ 2731 2731 "phf_shared", 2732 - "rand", 2732 + "rand 0.8.5", 2733 2733 ] 2734 2734 2735 2735 [[package]] ··· 2942 2942 "polars-row", 2943 2943 "polars-schema", 2944 2944 "polars-utils", 2945 - "rand", 2945 + "rand 0.8.5", 2946 2946 "rand_distr", 2947 2947 "rayon", 2948 2948 "regex", ··· 2984 2984 "polars-row", 2985 2985 "polars-time", 2986 2986 "polars-utils", 2987 - "rand", 2987 + "rand 0.8.5", 2988 2988 "rayon", 2989 2989 ] 2990 2990 ··· 3235 3235 "polars-plan", 3236 3236 "polars-time", 3237 3237 "polars-utils", 3238 - "rand", 3238 + "rand 0.8.5", 3239 3239 "regex", 3240 3240 "serde", 3241 3241 "sqlparser", ··· 3263 3263 "polars-parquet", 3264 3264 "polars-plan", 3265 3265 "polars-utils", 3266 - "rand", 3266 + "rand 0.8.5", 3267 3267 "rayon", 3268 3268 "recursive", 3269 3269 "slotmap", ··· 3312 3312 "num-traits", 3313 3313 "once_cell", 3314 3314 "polars-error", 3315 - "rand", 3315 + "rand 0.8.5", 3316 3316 "raw-cpuid", 3317 3317 "rayon", 3318 3318 "stacker", ··· 3338 3338 source = "registry+https://github.com/rust-lang/crates.io-index" 3339 3339 checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" 3340 3340 dependencies = [ 3341 - "zerocopy", 3341 + "zerocopy 0.7.35", 3342 3342 ] 3343 3343 3344 3344 [[package]] ··· 3424 3424 dependencies = [ 3425 3425 "bytes", 3426 3426 "getrandom 0.2.15", 3427 - "rand", 3427 + "rand 0.8.5", 3428 3428 "ring", 3429 3429 "rustc-hash", 3430 3430 "rustls", ··· 3472 3472 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 3473 3473 dependencies = [ 3474 3474 "libc", 3475 - "rand_chacha", 3476 - "rand_core", 3475 + "rand_chacha 0.3.1", 3476 + "rand_core 0.6.4", 3477 + ] 3478 + 3479 + [[package]] 3480 + name = "rand" 3481 + version = "0.9.0" 3482 + source = "registry+https://github.com/rust-lang/crates.io-index" 3483 + checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" 3484 + dependencies = [ 3485 + "rand_chacha 0.9.0", 3486 + "rand_core 0.9.3", 3487 + "zerocopy 0.8.24", 3477 3488 ] 3478 3489 3479 3490 [[package]] ··· 3483 3494 checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 3484 3495 dependencies = [ 3485 3496 "ppv-lite86", 3486 - "rand_core", 3497 + "rand_core 0.6.4", 3498 + ] 3499 + 3500 + [[package]] 3501 + name = "rand_chacha" 3502 + version = "0.9.0" 3503 + source = "registry+https://github.com/rust-lang/crates.io-index" 3504 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 3505 + dependencies = [ 3506 + "ppv-lite86", 3507 + "rand_core 0.9.3", 3487 3508 ] 3488 3509 3489 3510 [[package]] ··· 3496 3517 ] 3497 3518 3498 3519 [[package]] 3520 + name = "rand_core" 3521 + version = "0.9.3" 3522 + source = "registry+https://github.com/rust-lang/crates.io-index" 3523 + checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 3524 + dependencies = [ 3525 + "getrandom 0.3.1", 3526 + ] 3527 + 3528 + [[package]] 3499 3529 name = "rand_distr" 3500 3530 version = "0.4.3" 3501 3531 source = "registry+https://github.com/rust-lang/crates.io-index" 3502 3532 checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" 3503 3533 dependencies = [ 3504 3534 "num-traits", 3505 - "rand", 3535 + "rand 0.8.5", 3506 3536 ] 3507 3537 3508 3538 [[package]] ··· 3726 3756 "num-traits", 3727 3757 "pkcs1", 3728 3758 "pkcs8", 3729 - "rand_core", 3759 + "rand_core 0.6.4", 3730 3760 "signature", 3731 3761 "spki", 3732 3762 "subtle", ··· 3743 3773 "borsh", 3744 3774 "bytes", 3745 3775 "num-traits", 3746 - "rand", 3776 + "rand 0.8.5", 3747 3777 "rkyv", 3748 3778 "serde", 3749 3779 "serde_json", ··· 3884 3914 version = "0.1.0" 3885 3915 dependencies = [ 3886 3916 "actix-web", 3917 + "aes", 3887 3918 "anyhow", 3888 3919 "chrono", 3920 + "ctr", 3889 3921 "dotenv", 3890 3922 "hex", 3891 3923 "jsonwebtoken", 3892 3924 "md5", 3893 3925 "owo-colors", 3894 3926 "quick-xml", 3927 + "rand 0.9.0", 3895 3928 "redis", 3896 3929 "reqwest", 3897 3930 "serde", ··· 4062 4095 checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" 4063 4096 dependencies = [ 4064 4097 "pkcs8", 4065 - "rand_core", 4098 + "rand_core 0.6.4", 4066 4099 "signature", 4067 4100 "zeroize", 4068 4101 ] ··· 4074 4107 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 4075 4108 dependencies = [ 4076 4109 "digest", 4077 - "rand_core", 4110 + "rand_core 0.6.4", 4078 4111 ] 4079 4112 4080 4113 [[package]] ··· 4307 4340 "memchr", 4308 4341 "once_cell", 4309 4342 "percent-encoding", 4310 - "rand", 4343 + "rand 0.8.5", 4311 4344 "rsa", 4312 4345 "serde", 4313 4346 "sha1", ··· 4346 4379 "md-5", 4347 4380 "memchr", 4348 4381 "once_cell", 4349 - "rand", 4382 + "rand 0.8.5", 4350 4383 "serde", 4351 4384 "serde_json", 4352 4385 "sha2", ··· 4947 4980 "futures-sink", 4948 4981 "http 1.2.0", 4949 4982 "httparse", 4950 - "rand", 4983 + "rand 0.8.5", 4951 4984 "ring", 4952 4985 "rustls-pki-types", 4953 4986 "tokio", ··· 5667 5700 checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 5668 5701 dependencies = [ 5669 5702 "byteorder", 5670 - "zerocopy-derive", 5703 + "zerocopy-derive 0.7.35", 5704 + ] 5705 + 5706 + [[package]] 5707 + name = "zerocopy" 5708 + version = "0.8.24" 5709 + source = "registry+https://github.com/rust-lang/crates.io-index" 5710 + checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" 5711 + dependencies = [ 5712 + "zerocopy-derive 0.8.24", 5671 5713 ] 5672 5714 5673 5715 [[package]] ··· 5675 5717 version = "0.7.35" 5676 5718 source = "registry+https://github.com/rust-lang/crates.io-index" 5677 5719 checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 5720 + dependencies = [ 5721 + "proc-macro2", 5722 + "quote", 5723 + "syn 2.0.98", 5724 + ] 5725 + 5726 + [[package]] 5727 + name = "zerocopy-derive" 5728 + version = "0.8.24" 5729 + source = "registry+https://github.com/rust-lang/crates.io-index" 5730 + checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" 5678 5731 dependencies = [ 5679 5732 "proc-macro2", 5680 5733 "quote",
+3
crates/scrobbler/Cargo.toml
··· 37 37 ], default-features = false } 38 38 quick-xml = { version = "0.37.4", features = ["serialize"] } 39 39 chrono = { version = "0.4.39", features = ["serde"] } 40 + aes = "0.8.4" 41 + ctr = "0.9.2" 42 + rand = "0.9.0"
+101
crates/scrobbler/src/auth.rs
··· 1 + use std::collections::BTreeMap; 2 + use anyhow::Error; 3 + use sqlx::{Pool, Postgres}; 4 + use std::env; 5 + use jsonwebtoken::DecodingKey; 6 + use jsonwebtoken::EncodingKey; 7 + use jsonwebtoken::Header; 8 + use jsonwebtoken::Validation; 9 + use serde::{Deserialize, Serialize}; 10 + 11 + use crate::repo; 12 + use crate::signature::generate_signature; 13 + 14 + #[derive(Debug, Serialize, Deserialize)] 15 + pub struct Claims { 16 + exp: usize, 17 + iat: usize, 18 + did: String, 19 + } 20 + 21 + pub async fn authenticate( 22 + pool: &Pool<Postgres>, 23 + api_key: &str, 24 + api_sig: &str, 25 + session_key: &str, 26 + form: &BTreeMap<String, String>, 27 + ) -> Result<(), Error> { 28 + let claims = decode_token(session_key)?; 29 + 30 + let user_apikey = repo::api_key::get_apikey(pool, api_key, &claims.did).await?; 31 + 32 + if user_apikey.is_none() { 33 + return Err(Error::msg("Invalid API key")); 34 + } 35 + 36 + let user_apikey = user_apikey.unwrap(); 37 + 38 + let signature = generate_signature(form, &user_apikey.shared_secret); 39 + 40 + if signature != api_sig { 41 + return Err(Error::msg("Invalid signature")); 42 + } 43 + 44 + Ok(()) 45 + } 46 + 47 + pub async fn extract_did(pool: &Pool<Postgres>, form: &BTreeMap<String, String>) -> Result<String, Error> { 48 + let apikey = form.get("api_key").ok_or_else(|| Error::msg("Missing api_key"))?; 49 + let user = repo::user::get_user_by_apikey(pool, apikey).await?; 50 + let did = user.ok_or_else(|| Error::msg("Corresponding user not found"))?.did; 51 + Ok(did) 52 + } 53 + 54 + pub fn generate_token(did: &str) -> Result<String, Error> { 55 + if env::var("JWT_SECRET").is_err() { 56 + return Err(Error::msg("JWT_SECRET is not set")); 57 + } 58 + 59 + let claims = Claims { 60 + exp: chrono::Utc::now().timestamp() as usize + 3600, 61 + iat: chrono::Utc::now().timestamp() as usize, 62 + did: did.to_string(), 63 + }; 64 + 65 + jsonwebtoken::encode( 66 + &Header::default(), 67 + &claims, 68 + &EncodingKey::from_secret(env::var("JWT_SECRET")?.as_ref()), 69 + ) 70 + .map_err(Into::into) 71 + } 72 + 73 + pub fn decode_token(token: &str) -> Result<Claims, Error> { 74 + if env::var("JWT_SECRET").is_err() { 75 + return Err(Error::msg("JWT_SECRET is not set")); 76 + } 77 + 78 + jsonwebtoken::decode::<Claims>( 79 + token, 80 + &DecodingKey::from_secret(env::var("JWT_SECRET")?.as_ref()), 81 + &Validation::default(), 82 + ) 83 + .map(|data| data.claims) 84 + .map_err(Into::into) 85 + } 86 + 87 + #[cfg(test)] 88 + mod tests { 89 + use dotenv::dotenv; 90 + 91 + use super::*; 92 + 93 + #[test] 94 + fn test_generate_token() { 95 + dotenv().ok(); 96 + let token = generate_token("did:plc:7vdlgi2bflelz7mmuxoqjfcr").unwrap(); 97 + let claims = decode_token(&token).unwrap(); 98 + 99 + assert_eq!(claims.did, "did:plc:7vdlgi2bflelz7mmuxoqjfcr"); 100 + } 101 + }
+6
crates/scrobbler/src/cache.rs
··· 38 38 .query::<()>(&mut con)?; 39 39 Ok(()) 40 40 } 41 + 42 + pub fn exists(&self, key: &str) -> Result<bool, Error> { 43 + let mut con = self.client.get_connection()?; 44 + let result: bool = redis::cmd("EXISTS").arg(key).query(&mut con)?; 45 + Ok(result) 46 + } 41 47 }
+22
crates/scrobbler/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 + }
+48 -106
crates/scrobbler/src/handlers.rs
··· 1 1 use actix_web::{post, web, HttpResponse, Responder}; 2 2 use serde_json::json; 3 + use sqlx::{Pool, Postgres}; 3 4 use std::collections::BTreeMap; 4 - use crate::musicbrainz::client::MusicbrainzClient; 5 - use crate::signature::generate_signature; 6 - use crate::models::Scrobble; 7 - use crate::spotify::client::SpotifyClient; 8 - 9 - fn parse_batch(form: &BTreeMap<String, String>) -> Vec<Scrobble> { 10 - let mut result = vec![]; 11 - let mut index = 0; 5 + use std::sync::Arc; 6 + use crate::auth::authenticate; 7 + use crate::cache::Cache; 8 + use crate::params::validate_required_params; 9 + use crate::response::build_response; 10 + use crate::scrobbler::scrobble; 12 11 13 - loop { 14 - let artist = form.get(&format!("artist[{}]", index)); 15 - let track = form.get(&format!("track[{}]", index)); 16 - let timestamp = form.get(&format!("timestamp[{}]", index)); 17 - 18 - if artist.is_none() || track.is_none() || timestamp.is_none() { 19 - break; 20 - } 21 - 22 - let album = form.get(&format!("album[{}]", index)).cloned(); 23 - let context = form.get(&format!("context[{}]", index)).cloned(); 24 - let stream_id = form.get(&format!("streamId[{}]", index)).cloned(); 25 - let chosen_by_user = form.get(&format!("chosenByUser[{}]", index)).and_then(|s| s.parse().ok()); 26 - let track_number = form.get(&format!("trackNumber[{}]", index)).and_then(|s| s.parse().ok()); 27 - let mbid = form.get(&format!("mbid[{}]", index)).cloned(); 28 - let album_artist = form.get(&format!("albumArtist[{}]", index)).cloned(); 29 - let duration = form.get(&format!("duration[{}]", index)).and_then(|s| s.parse().ok()); 30 - 31 - result.push(Scrobble { 32 - artist: artist.unwrap().to_string(), 33 - track: track.unwrap().to_string(), 34 - timestamp: timestamp.unwrap().parse().unwrap_or(0), 35 - album, 36 - context, 37 - stream_id, 38 - chosen_by_user, 39 - track_number, 40 - mbid, 41 - album_artist, 42 - duration, 43 - }); 44 - 45 - index += 1; 46 - } 47 - 48 - result 49 - } 50 12 51 13 #[post("/2.0")] 52 - pub async fn scrobble(form: web::Form<BTreeMap<String, String>>) -> impl Responder { 53 - /* 54 - let secret = "your_app_secret"; 55 - let sig_check = generate_signature(&form, secret); 56 - if form.get("api_sig").map(String::as_str) != Some(&sig_check) { 57 - return HttpResponse::Forbidden().json(json!({ 58 - "error": 13, 59 - "message": "Invalid API signature" 60 - })); 61 - } 62 - */ 63 - 64 - let method = form.get("method").map(String::as_str); 65 - if method != Some("track.scrobble") { 66 - return HttpResponse::BadRequest().json(json!({ 67 - "error": 3, 68 - "message": "Method not supported" 69 - })); 70 - } 71 - 72 - let scrobbles = parse_batch(&form); 73 - 74 - if scrobbles.is_empty() { 75 - return HttpResponse::BadRequest().json(json!({ 76 - "error": 6, 77 - "message": "Missing or invalid scrobble fields" 78 - })); 79 - } 14 + pub async fn handle_scrobble( 15 + data: web::Data<Arc<Pool<Postgres>>>, 16 + cache: web::Data<Cache>, 17 + form: web::Form<BTreeMap<String, String>>, 18 + ) -> impl Responder { 19 + let conn = data.get_ref().clone(); 20 + let cache = cache.get_ref().clone(); 80 21 81 - // You can now save these scrobbles to your database here. 82 - let client = MusicbrainzClient::new(); 83 - // let result = client.get_recording("47fb34da-5d07-4772-8cdb-af352699ee67").await; 84 - let result = client.search(r#" 85 - recording:"Don't stay" AND artist:"Linkin Park" 86 - "#).await; 87 - println!("Musicbrainz result: {:#?}", result); 88 - 89 - /* 90 - let client = SpotifyClient::new("BQDhXOoiiSNCmmOKFvtvpuT3gwXJGOdXvjpkfb4JTc8GPLhNOhLrgy_tAVA7i8c3VipA4mbQfwvc9rAGDaNyzpVw26SXtQXFMNw_na2VRAeBXMcHMqrJ-Cfg4XQoLzAvQwX8RkuRAKtaBpMSFtwiHYHeYj2GybiqinRZ8ZDNRzIn3GvoYQjcYqfEKR39iNHtlToDPkdPxO1caZh8DDc2VltMLxOUyNs8po_OLpp6-7WgED3_CmHEbOfdc_DD4-btRcsmvri8O58lOioxBgCgrKx0Ww-xq7oNk0-mdDJcUat805Fuh2PHRIoWK2rOLtbVAtU8PnpfLzUbbiejxBfXubl5J3EST7tB9N_OkVz8ZQs92tp-QQk"); 91 - let results = match client.search("track:one more time artist:daft punk").await { 92 - Ok(res) => res, 22 + let params = match validate_required_params( 23 + &form, 24 + &["api_key", "api_sig", "sk", "method"], 25 + ) { 26 + Ok(params) => params, 93 27 Err(e) => { 94 - return HttpResponse::InternalServerError().json(json!({ 95 - "error": 9, 96 - "message": format!("Internal server error: {}", e) 28 + return HttpResponse::BadRequest().json(json!({ 29 + "error": 5, 30 + "message": format!("{}", e) 97 31 })); 98 32 } 99 33 }; 100 34 101 - println!("Search results: {:?}", results); 102 - */ 35 + if let Err(e) = authenticate( 36 + &conn, 37 + &params[0], 38 + &params[1], 39 + &params[2], 40 + &form 41 + ).await { 42 + return HttpResponse::Forbidden().json(json!({ 43 + "error": 2, 44 + "message": format!("Authentication failed: {}", e) 45 + })); 46 + } 103 47 104 - let response = json!({ 105 - "scrobbles": { 106 - "@attr": { 107 - "accepted": scrobbles.len().to_string(), 108 - "ignored": "0" 109 - }, 110 - "scrobble": scrobbles.iter().map(|s| json!({ 111 - "artist": { "#text": s.artist, "corrected": "0" }, 112 - "track": { "#text": s.track, "corrected": "0" }, 113 - "album": { "#text": s.album.clone().unwrap_or_default(), "corrected": "0" }, 114 - "timestamp": s.timestamp.to_string(), 115 - "ignoredMessage": { "#text": "", "code": "0" } 116 - })).collect::<Vec<_>>() 48 + match scrobble(&conn, &cache, &form).await { 49 + Ok(scrobbles) => HttpResponse::Ok().json(build_response(scrobbles)), 50 + Err(e) => { 51 + if e.to_string().contains("Timestamp") { 52 + return HttpResponse::BadRequest().json(json!({ 53 + "error": 6, 54 + "message": e.to_string() 55 + })); 56 + } 57 + HttpResponse::BadRequest().json(json!({ 58 + "error": 4, 59 + "message": format!("Failed to parse scrobbles: {}", e) 60 + })) 117 61 } 118 - }); 119 - 120 - HttpResponse::Ok().json(response) 62 + } 121 63 }
+30 -9
crates/scrobbler/src/main.rs
··· 1 1 pub mod handlers; 2 - pub mod models; 3 2 pub mod signature; 4 3 pub mod musicbrainz; 5 4 pub mod spotify; 6 5 pub mod xata; 7 6 pub mod cache; 8 - 9 - use std::env; 7 + pub mod auth; 8 + pub mod params; 9 + pub mod scrobbler; 10 + pub mod response; 11 + pub mod crypto; 12 + pub mod rocksky; 13 + pub mod repo; 14 + pub mod types; 10 15 11 - use actix_web::{App, HttpServer}; 16 + use std::{env, sync::Arc}; 17 + use actix_web::{web::Data, App, HttpServer}; 18 + use anyhow::Error; 19 + use cache::Cache; 20 + use dotenv::dotenv; 12 21 use owo_colors::OwoColorize; 22 + use sqlx::postgres::PgPoolOptions; 13 23 14 24 #[tokio::main] 15 - async fn main() -> std::io::Result<()> { 25 + async fn main() -> Result<(), Error> { 26 + dotenv().ok(); 27 + 28 + let cache = Cache::new()?; 29 + 30 + let pool = PgPoolOptions::new().max_connections(5).connect(&env::var("XATA_POSTGRES_URL")?).await?; 31 + let conn = Arc::new(pool); 32 + 16 33 let host = env::var("SCROBBLE_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 17 34 let port = env::var("SCROBBLE_PORT") 18 35 .unwrap_or_else(|_| "7882".to_string()) ··· 21 38 22 39 println!("Starting Scrobble server @ {}", format!("{}:{}", host, port).green()); 23 40 24 - HttpServer::new(|| { 41 + HttpServer::new(move || { 25 42 App::new() 26 - .service(handlers::scrobble) 43 + .app_data(Data::new(conn.clone())) 44 + .app_data(Data::new(cache.clone())) 45 + .service(handlers::handle_scrobble) 27 46 }) 28 47 .bind((host, port))? 29 48 .run() 30 - .await 31 - } 49 + .await?; 50 + 51 + Ok(()) 52 + }
-16
crates/scrobbler/src/models.rs
··· 1 - use serde::Deserialize; 2 - 3 - #[derive(Debug, Deserialize)] 4 - pub struct Scrobble { 5 - pub artist: String, 6 - pub track: String, 7 - pub timestamp: i64, 8 - pub album: Option<String>, 9 - pub context: Option<String>, 10 - pub stream_id: Option<String>, 11 - pub chosen_by_user: Option<u8>, 12 - pub track_number: Option<u32>, 13 - pub mbid: Option<String>, 14 - pub album_artist: Option<String>, 15 - pub duration: Option<u32>, 16 - }
+5 -5
crates/scrobbler/src/musicbrainz/artist.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 3 - #[derive(Debug, Deserialize)] 3 + #[derive(Debug, Deserialize, Clone)] 4 4 pub struct Artist { 5 5 pub name: String, 6 6 #[serde(rename = "sort-name")] ··· 29 29 pub aliases: Option<Vec<Alias>>, 30 30 } 31 31 32 - #[derive(Debug, Deserialize)] 32 + #[derive(Debug, Deserialize, Clone)] 33 33 pub struct ArtistCredit { 34 34 pub joinphrase: Option<String>, 35 35 pub name: String, 36 36 pub artist: Artist, 37 37 } 38 38 39 - #[derive(Debug, Deserialize)] 39 + #[derive(Debug, Deserialize, Clone)] 40 40 pub struct Alias { 41 41 pub name: String, 42 42 #[serde(rename = "sort-name")] ··· 51 51 pub ended: Option<bool>, 52 52 } 53 53 54 - #[derive(Debug, Deserialize)] 54 + #[derive(Debug, Deserialize, Clone)] 55 55 pub struct Area { 56 56 pub disambiguation: Option<String>, 57 57 pub id: String, ··· 65 65 pub iso_3166_1_codes: Option<Vec<String>>, 66 66 } 67 67 68 - #[derive(Debug, Deserialize)] 68 + #[derive(Debug, Deserialize, Clone)] 69 69 pub struct LifeSpan { 70 70 pub begin: Option<String>, 71 71 pub end: Option<String>,
+2 -2
crates/scrobbler/src/musicbrainz/label.rs
··· 2 2 3 3 use super::artist::{Area, LifeSpan}; 4 4 5 - #[derive(Debug, Deserialize)] 5 + #[derive(Debug, Deserialize, Clone)] 6 6 pub struct Label { 7 7 #[serde(rename = "type-id")] 8 8 pub type_id: String, ··· 22 22 pub life_span: Option<LifeSpan>, 23 23 } 24 24 25 - #[derive(Debug, Deserialize)] 25 + #[derive(Debug, Deserialize, Clone)] 26 26 pub struct LabelInfo { 27 27 #[serde(rename = "catalog-number")] 28 28 pub catalog_number: String,
+1 -1
crates/scrobbler/src/musicbrainz/recording.rs
··· 10 10 pub created: String, 11 11 } 12 12 13 - #[derive(Debug, Deserialize)] 13 + #[derive(Debug, Deserialize, Clone)] 14 14 pub struct Recording { 15 15 #[serde(rename = "first-release-date")] 16 16 pub first_release_date: Option<String>,
+7 -7
crates/scrobbler/src/musicbrainz/release.rs
··· 6 6 recording::Recording, 7 7 }; 8 8 9 - #[derive(Debug, Deserialize)] 9 + #[derive(Debug, Deserialize, Clone)] 10 10 pub struct Release { 11 11 #[serde(rename = "release-events")] 12 12 pub release_events: Option<Vec<ReleaseEvent>>, ··· 35 35 pub asin: Option<String>, 36 36 } 37 37 38 - #[derive(Debug, Deserialize)] 38 + #[derive(Debug, Deserialize, Clone)] 39 39 pub struct CoverArtArchive { 40 40 pub back: bool, 41 41 pub artwork: bool, ··· 44 44 pub darkened: bool, 45 45 } 46 46 47 - #[derive(Debug, Deserialize)] 47 + #[derive(Debug, Deserialize, Clone)] 48 48 pub struct ReleaseEvent { 49 49 pub area: Option<Area>, 50 50 pub date: String, 51 51 } 52 52 53 - #[derive(Debug, Deserialize)] 53 + #[derive(Debug, Deserialize, Clone)] 54 54 pub struct TextRepresentation { 55 55 pub language: Option<String>, 56 56 pub script: Option<String>, 57 57 } 58 58 59 - #[derive(Debug, Deserialize)] 59 + #[derive(Debug, Deserialize, Clone)] 60 60 pub struct Media { 61 61 #[serde(rename = "format-id")] 62 62 pub format_id: Option<String>, ··· 71 71 pub format: Option<String>, 72 72 } 73 73 74 - #[derive(Debug, Deserialize)] 74 + #[derive(Debug, Deserialize, Clone)] 75 75 pub struct Disc { 76 76 pub offset: Option<u32>, 77 77 pub sectors: u32, ··· 79 79 pub offsets: Option<Vec<u32>>, 80 80 } 81 81 82 - #[derive(Debug, Deserialize)] 82 + #[derive(Debug, Deserialize, Clone)] 83 83 pub struct Track { 84 84 pub length: i64, 85 85 pub id: String,
+43
crates/scrobbler/src/params.rs
··· 1 + use std::collections::BTreeMap; 2 + 3 + use anyhow::Error; 4 + 5 + pub fn validate_required_params( 6 + form: &BTreeMap<String, String>, 7 + required_params: &[&str], 8 + ) -> Result<Vec<String>, Error> { 9 + let has_artist = form.keys().any(|k| k.starts_with("artist")); 10 + let has_track = form.keys().any(|k| k.starts_with("track")); 11 + let has_timestamp = form.keys().any(|k| k.starts_with("timestamp")); 12 + 13 + if !has_artist { 14 + return Err(Error::msg(format!("Missing required parameter: artist"))); 15 + } 16 + 17 + if !has_track { 18 + return Err(Error::msg(format!("Missing required parameter: track"))); 19 + } 20 + 21 + if !has_timestamp { 22 + return Err(Error::msg(format!("Missing required parameter: timestamp"))); 23 + } 24 + 25 + for &param in required_params { 26 + if !form.contains_key(param) { 27 + return Err(Error::msg(format!("Missing required parameter: {}", param))); 28 + } 29 + } 30 + 31 + let method = form.get("method").map(String::as_str); 32 + if method != Some("track.scrobble") { 33 + return Err(Error::msg(format!( 34 + "Unsupported method: {}", 35 + method.unwrap() 36 + ))); 37 + } 38 + 39 + Ok(required_params 40 + .iter() 41 + .map(|&s| form.get(s).unwrap().to_string()) 42 + .collect()) 43 + }
+23
crates/scrobbler/src/repo/api_key.rs
··· 1 + use anyhow::Error; 2 + use sqlx::{Pool, Postgres}; 3 + 4 + use crate::xata::api_key::ApiKey; 5 + 6 + 7 + pub async fn get_apikey(pool: &Pool<Postgres>, apikey: &str, did: &str) -> Result<Option<ApiKey>, Error> { 8 + let results: Vec<ApiKey> = sqlx::query_as(r#" 9 + SELECT * FROM api_keys 10 + LEFT JOIN users ON api_keys.user_id = users.xata_id 11 + WHERE api_keys.api_key = $1 AND users.did = $2 12 + "#) 13 + .bind(apikey) 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 + }
+4
crates/scrobbler/src/repo/mod.rs
··· 1 + pub mod api_key; 2 + pub mod spotify_token; 3 + pub mod track; 4 + pub mod user;
+37
crates/scrobbler/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/scrobbler/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 + }
+23
crates/scrobbler/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_apikey(pool: &Pool<Postgres>, apikey: &str) -> Result<Option<User>, Error> { 8 + let results: Vec<User> = sqlx::query_as(r#" 9 + SELECT * FROM users 10 + LEFT JOIN api_keys ON users.xata_id = api_keys.user_id 11 + WHERE api_keys.api_key = $1 12 + "#) 13 + .bind(apikey) 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 +
+28
crates/scrobbler/src/response.rs
··· 1 + use serde_json::{json, Value}; 2 + 3 + use crate::types::Scrobble; 4 + 5 + pub fn build_response(scrobbles: Vec<Scrobble>) -> Value { 6 + json!({ 7 + "scrobbles": { 8 + "@attr": { 9 + "accepted": scrobbles.len().to_string(), 10 + "ignored": "0" 11 + }, 12 + "scrobble": scrobbles.iter().map(|s| json!({ 13 + "artist": { "#text": s.artist, "corrected": "0" }, 14 + "track": { "#text": s.track, "corrected": "0" }, 15 + "album": { "#text": s.album.clone().unwrap_or_default(), "corrected": "0" }, 16 + "timestamp": s.timestamp.to_string(), 17 + "ignoredMessage": { 18 + "#text": "", 19 + "code": match s.ignored { 20 + Some(true) => "1", 21 + Some(false) => "0", 22 + None => "0" 23 + } 24 + } 25 + })).collect::<Vec<_>>() 26 + } 27 + }) 28 + }
+38
crates/scrobbler/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); 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 + }
+204
crates/scrobbler/src/scrobbler.rs
··· 1 + use std::{collections::BTreeMap, env}; 2 + 3 + use anyhow::Error; 4 + use owo_colors::OwoColorize; 5 + use rand::Rng; 6 + use sqlx::{Pool, Postgres}; 7 + 8 + use crate::{ 9 + auth::extract_did, 10 + cache::Cache, crypto::decrypt_aes_256_ctr, 11 + types::Scrobble, 12 + musicbrainz::client::MusicbrainzClient, 13 + repo, 14 + rocksky, 15 + spotify::{ 16 + client::SpotifyClient, 17 + refresh_token 18 + }, types::Track 19 + }; 20 + 21 + fn parse_batch(form: &BTreeMap<String, String>) -> Result<Vec<Scrobble>, Error> { 22 + let mut result = vec![]; 23 + let mut index = 0; 24 + 25 + loop { 26 + let artist = form.get(&format!("artist[{}]", index)); 27 + let track = form.get(&format!("track[{}]", index)); 28 + let timestamp = form.get(&format!("timestamp[{}]", index)); 29 + 30 + if artist.is_none() || track.is_none() || timestamp.is_none() { 31 + break; 32 + } 33 + 34 + let album = form.get(&format!("album[{}]", index)) 35 + .cloned() 36 + .map(|x| x.trim().to_string()); 37 + let context = form.get(&format!("context[{}]", index)) 38 + .cloned() 39 + .map(|x| x.trim().to_string()); 40 + let stream_id = form.get(&format!("streamId[{}]", index)) 41 + .and_then(|s| s.trim().parse().ok()); 42 + let chosen_by_user = form 43 + .get(&format!("chosenByUser[{}]", index)) 44 + .and_then(|s| s.trim().parse().ok()); 45 + let track_number = form 46 + .get(&format!("trackNumber[{}]", index)) 47 + .and_then(|s| s.trim().parse().ok()); 48 + let mbid = form.get(&format!("mbid[{}]", index)).cloned(); 49 + let album_artist = form.get(&format!("albumArtist[{}]", index)).map(|x| x.trim().to_string()); 50 + let duration = form 51 + .get(&format!("duration[{}]", index)) 52 + .and_then(|s| s.trim().parse().ok()); 53 + 54 + let timestamp = timestamp.unwrap().trim().parse().unwrap_or( 55 + chrono::Utc::now().timestamp() as u64, 56 + ); 57 + 58 + // validate timestamp, must be in the past (between 14 days before to present) 59 + let now = chrono::Utc::now().timestamp() as u64; 60 + if timestamp > now { 61 + return Err(Error::msg("Timestamp is in the future")); 62 + } 63 + 64 + if timestamp < now - 14 * 24 * 60 * 60 { 65 + return Err(Error::msg("Timestamp is too old")); 66 + } 67 + 68 + result.push(Scrobble { 69 + artist: artist.unwrap().trim().to_string(), 70 + track: track.unwrap().trim().to_string(), 71 + timestamp, 72 + album, 73 + context, 74 + stream_id, 75 + chosen_by_user, 76 + track_number, 77 + mbid, 78 + album_artist, 79 + duration, 80 + ignored: None, 81 + }); 82 + 83 + index += 1; 84 + } 85 + 86 + Ok(result) 87 + } 88 + 89 + pub async fn scrobble(pool: &Pool<Postgres>, cache: &Cache, form: &BTreeMap<String, String>) -> Result<Vec<Scrobble>, Error> { 90 + let mut scrobbles = parse_batch(form)?; 91 + 92 + if scrobbles.is_empty() { 93 + return Err(Error::msg("No scrobbles found")); 94 + } 95 + 96 + let did = extract_did(pool, form).await?; 97 + 98 + let spofity_tokens = repo::spotify_token::get_spotify_tokens(pool, 100).await?; 99 + 100 + if spofity_tokens.is_empty() { 101 + return Err(Error::msg("No Spotify tokens found")); 102 + } 103 + 104 + let mb_client = MusicbrainzClient::new(); 105 + 106 + for scrobble in &mut scrobbles { 107 + /* 108 + 0. check if scrobble is cached 109 + 1. if mbid is present, check if it exists in the database 110 + 2. if it exists, scrobble 111 + 3. if it doesn't exist, check if it exists in Musicbrainz (using mbid) 112 + 4. if it exists, get album art from spotify and scrobble 113 + 5. if it doesn't exist, check if it exists in Spotify 114 + 6. if it exists, scrobble 115 + 7. if it doesn't exist, check if it exists in Musicbrainz (using track and artist) 116 + 8. if it exists, scrobble 117 + 9. if it doesn't exist, skip unknown track 118 + */ 119 + let key = format!("{} - {}", scrobble.artist.to_lowercase(), scrobble.track.to_lowercase()); 120 + let cached = cache.get(&key)?; 121 + if cached.is_some() { 122 + println!("{}", format!("Cached: {}", key).yellow()); 123 + let track = serde_json::from_str::<Track>(&cached.unwrap())?; 124 + scrobble.album = Some(track.album.clone()); 125 + rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?; 126 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 127 + continue; 128 + } 129 + 130 + if let Some(mbid) = &scrobble.mbid { 131 + // let result = repo::track::get_track_by_mbid(pool, mbid).await?; 132 + let result = mb_client.get_recording(mbid).await?; 133 + println!("{}", "Musicbrainz (mbid)".yellow()); 134 + scrobble.album = Some(Track::from(result.clone()).album); 135 + rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?; 136 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 137 + continue; 138 + } 139 + 140 + let result = repo::track::get_track(pool, &scrobble.track, &scrobble.artist).await?; 141 + 142 + if let Some(track) = result { 143 + println!("{}", "Xata (track)".yellow()); 144 + scrobble.album = Some(track.album.clone()); 145 + rocksky::scrobble(cache, &did, track.into(), scrobble.timestamp).await?; 146 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 147 + continue; 148 + } 149 + 150 + // we need to pick a random token to avoid Spotify rate limiting 151 + // and to avoid using the same token for all scrobbles 152 + // this is a simple way to do it, but we can improve it later 153 + // by using a more sophisticated algorithm 154 + // or by using a token pool 155 + let mut rng = rand::rng(); 156 + let random_index = rng.random_range(0..spofity_tokens.len()); 157 + let spotify_token = &spofity_tokens[random_index]; 158 + 159 + let spotify_token = decrypt_aes_256_ctr( 160 + &spotify_token.refresh_token, 161 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)? 162 + )?; 163 + 164 + let spotify_token = refresh_token(&spotify_token).await?; 165 + let spotify_client = SpotifyClient::new(&spotify_token.access_token); 166 + 167 + let result = spotify_client.search(&format!(r#"track:"{}" artist:"{}""#, scrobble.track, scrobble.artist)).await?; 168 + 169 + if let Some(track) = result.tracks.items.first() { 170 + println!("{}", "Spotify (track)".yellow()); 171 + scrobble.album = Some(track.album.name.clone()); 172 + let mut track = track.clone(); 173 + 174 + if let Some(album) = spotify_client.get_album(&track.album.id).await? { 175 + track.album = album; 176 + } 177 + 178 + rocksky::scrobble(cache, &did, track.into(), scrobble.timestamp).await?; 179 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 180 + continue; 181 + } 182 + 183 + let query = format!( 184 + r#"recording:"{}" AND artist:"{}""#, 185 + scrobble.track, scrobble.artist 186 + ); 187 + let result = mb_client.search(&query).await?; 188 + 189 + if let Some(recording) = result.recordings.first() { 190 + let result = mb_client.get_recording(&recording.id).await?; 191 + println!("{}", "Musicbrainz (recording)".yellow()); 192 + scrobble.album = Some(Track::from(result.clone()).album); 193 + rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?; 194 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 195 + continue; 196 + } 197 + 198 + println!("{} {} - {}, skipping", "Track not found: ".yellow(), scrobble.artist, scrobble.track); 199 + scrobble.ignored = Some(true); 200 + } 201 + 202 + 203 + Ok(scrobbles.clone()) 204 + }
+20 -1
crates/scrobbler/src/spotify/client.rs
··· 1 - use super::types::SearchResponse; 1 + use super::types::{Album, SearchResponse}; 2 2 use anyhow::Error; 3 3 4 4 pub const BASE_URL: &str = "https://api.spotify.com/v1"; ··· 27 27 let result = response.json().await?; 28 28 Ok(result) 29 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 + } 30 49 }
+30
crates/scrobbler/src/spotify/mod.rs
··· 1 + use std::env; 2 + 3 + use reqwest::Client; 4 + use types::AccessToken; 5 + use anyhow::Error; 6 + 1 7 pub mod client; 2 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 + }
+27 -10
crates/scrobbler/src/spotify/types.rs
··· 1 1 use serde::Deserialize; 2 2 3 - #[derive(Debug, Deserialize)] 3 + #[derive(Debug, Deserialize, Clone)] 4 4 pub struct SearchResponse { 5 5 pub tracks: Tracks, 6 6 } 7 7 8 - #[derive(Debug, Deserialize)] 8 + #[derive(Debug, Deserialize, Clone)] 9 9 pub struct Tracks { 10 10 pub href: String, 11 11 pub limit: u32, ··· 16 16 pub items: Vec<Track>, 17 17 } 18 18 19 - #[derive(Debug, Deserialize)] 19 + #[derive(Debug, Deserialize, Clone)] 20 20 pub struct Track { 21 21 pub album: Album, 22 22 pub artists: Vec<Artist>, ··· 39 39 pub uri: String, 40 40 } 41 41 42 - #[derive(Debug, Deserialize)] 42 + #[derive(Debug, Deserialize, Clone)] 43 43 pub struct Album { 44 44 pub album_type: String, 45 45 pub artists: Vec<Artist>, ··· 48 48 pub href: String, 49 49 pub id: String, 50 50 pub images: Vec<Image>, 51 - pub is_playable: Option<bool>, 52 51 pub name: String, 53 52 pub release_date: String, 54 53 pub release_date_precision: String, 55 54 pub total_tracks: u32, 56 55 #[serde(rename = "type")] 57 - pub kind: String, 56 + pub album_type_field: String, 58 57 pub uri: String, 58 + pub label: Option<String>, 59 + pub genres: Option<Vec<String>>, 60 + pub copyrights: Option<Vec<Copyright>>, 59 61 } 60 62 61 - #[derive(Debug, Deserialize)] 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)] 62 70 pub struct Artist { 63 71 pub external_urls: ExternalUrls, 64 72 pub href: String, ··· 67 75 #[serde(rename = "type")] 68 76 pub kind: String, 69 77 pub uri: String, 78 + pub images: Option<Vec<Image>>, 70 79 } 71 80 72 - #[derive(Debug, Deserialize)] 81 + #[derive(Debug, Deserialize, Clone)] 73 82 pub struct ExternalUrls { 74 83 pub spotify: String, 75 84 } 76 85 77 - #[derive(Debug, Deserialize)] 86 + #[derive(Debug, Deserialize, Clone)] 78 87 pub struct ExternalIds { 79 88 pub isrc: String, 80 89 } 81 90 82 - #[derive(Debug, Deserialize)] 91 + #[derive(Debug, Deserialize, Clone)] 83 92 pub struct Image { 84 93 pub height: u32, 85 94 pub width: u32, 86 95 pub url: String, 87 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 + }
+178
crates/scrobbler/src/types.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use crate::{musicbrainz, spotify, xata}; 4 + 5 + #[derive(Debug, Deserialize, Clone)] 6 + pub struct Scrobble { 7 + pub artist: String, 8 + pub track: String, 9 + pub timestamp: u64, 10 + pub album: Option<String>, 11 + pub context: Option<String>, 12 + pub stream_id: Option<String>, 13 + pub chosen_by_user: Option<u8>, 14 + pub track_number: Option<u32>, 15 + pub mbid: Option<String>, 16 + pub album_artist: Option<String>, 17 + pub duration: Option<u32>, 18 + pub ignored: Option<bool>, 19 + } 20 + 21 + #[derive(Debug, Serialize, Deserialize, Default)] 22 + #[serde(rename_all = "camelCase")] 23 + pub struct Track { 24 + pub title: String, 25 + pub album: String, 26 + pub artist: String, 27 + pub album_artist: Option<String>, 28 + pub duration: u32, 29 + pub mbid: Option<String>, 30 + pub track_number: u32, 31 + pub release_date: Option<String>, 32 + pub year: Option<u32>, 33 + pub disc_number: u32, 34 + pub album_art: Option<String>, 35 + pub spotify_link: Option<String>, 36 + pub label: Option<String>, 37 + pub artist_picture: Option<String>, 38 + pub timestamp: Option<u64>, 39 + } 40 + 41 + impl From<xata::track::Track> for Track { 42 + fn from(track: xata::track::Track) -> Self { 43 + Track { 44 + title: track.title, 45 + album: track.album, 46 + artist: track.artist, 47 + album_artist: Some(track.album_artist), 48 + album_art: track.album_art, 49 + spotify_link: track.spotify_link, 50 + label: track.label, 51 + artist_picture: None, 52 + timestamp: None, 53 + duration: track.duration as u32, 54 + mbid: track.mb_id, 55 + track_number: track.track_number as u32, 56 + disc_number: track.disc_number as u32, 57 + year: None, 58 + release_date: None, 59 + } 60 + } 61 + } 62 + 63 + impl From<musicbrainz::recording::Recording> for Track { 64 + fn from(recording: musicbrainz::recording::Recording) -> Self { 65 + let artist_credit = recording 66 + .artist_credit 67 + .unwrap_or_default() 68 + .first() 69 + .map(|credit| credit.name.clone()) 70 + .unwrap_or_default(); 71 + let releases = recording.releases.unwrap_or_default(); 72 + let album_artist = releases 73 + .first() 74 + .and_then(|release| release.artist_credit.first()) 75 + .map(|credit| credit.name.clone()); 76 + let album = releases 77 + .first() 78 + .map(|release| release.title.clone()) 79 + .unwrap_or_default(); 80 + Track { 81 + title: recording.title.clone(), 82 + album, 83 + artist: artist_credit, 84 + album_artist, 85 + duration: recording.length.unwrap_or_default(), 86 + year: recording 87 + .first_release_date 88 + .as_ref() 89 + .and_then(|date| date.split('-').next()) 90 + .and_then(|year| year.parse::<u32>().ok()), 91 + release_date: recording.first_release_date.clone(), 92 + track_number: releases 93 + .first() 94 + .and_then(|release| { 95 + release 96 + .media 97 + .as_ref() 98 + .and_then(|media| media.first()) 99 + .and_then(|media| { 100 + media 101 + .tracks 102 + .as_ref() 103 + .and_then(|tracks| tracks.first()) 104 + .map(|track| track.number.parse::<u32>().unwrap()) 105 + }) 106 + }) 107 + .unwrap_or_default(), 108 + disc_number: releases 109 + .first() 110 + .and_then(|release| { 111 + release 112 + .media 113 + .as_ref() 114 + .and_then(|media| media.first()) 115 + .map(|media| media.position) 116 + }) 117 + .unwrap_or_default(), 118 + ..Default::default() 119 + } 120 + } 121 + } 122 + 123 + impl From<&spotify::types::Track> for Track { 124 + fn from(track: &spotify::types::Track) -> Self { 125 + Track { 126 + title: track.name.clone(), 127 + album: track.album.name.clone(), 128 + artist: track 129 + .artists 130 + .iter() 131 + .map(|artist| artist.name.clone()) 132 + .collect::<Vec<_>>() 133 + .join(", "), 134 + album_artist: track 135 + .album 136 + .artists 137 + .first() 138 + .map(|artist| artist.name.clone()), 139 + duration: track.duration_ms as u32, 140 + album_art: track.album.images.first().map(|image| image.url.clone()), 141 + spotify_link: Some(track.external_urls.spotify.clone()), 142 + artist_picture: track.artists.first().and_then(|artist| { 143 + artist 144 + .images 145 + .as_ref() 146 + .and_then(|images| images.first().map(|image| image.url.clone())) 147 + }), 148 + track_number: track.track_number, 149 + disc_number: track.disc_number, 150 + release_date: match track.album.release_date_precision.as_str() { 151 + "day" => Some(track.album.release_date.clone()), 152 + _ => None, 153 + }, 154 + year: match track.album.release_date_precision.as_str() { 155 + "day" => Some( 156 + track 157 + .album 158 + .release_date 159 + .split('-') 160 + .next() 161 + .unwrap() 162 + .parse::<u32>() 163 + .unwrap(), 164 + ), 165 + "year" => Some(track.album.release_date.parse::<u32>().unwrap()), 166 + _ => None, 167 + }, 168 + label: track.album.label.clone(), 169 + ..Default::default() 170 + } 171 + } 172 + } 173 + 174 + impl From<spotify::types::Track> for Track { 175 + fn from(track: spotify::types::Track) -> Self { 176 + Track::from(&track) 177 + } 178 + }
+17
crates/scrobbler/src/xata/api_key.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 + pub struct ApiKey { 6 + pub xata_id: String, 7 + pub name: String, 8 + pub api_key: String, 9 + pub shared_secret: String, 10 + pub description: Option<String>, 11 + pub user_id: String, 12 + pub enabled: bool, 13 + #[serde(with = "chrono::serde::ts_seconds")] 14 + pub xata_createdat: DateTime<Utc>, 15 + #[serde(with = "chrono::serde::ts_seconds")] 16 + pub xata_updatedat: DateTime<Utc>, 17 + }
+1
crates/scrobbler/src/xata/mod.rs
··· 1 1 pub mod album; 2 + pub mod api_key; 2 3 pub mod artist; 3 4 pub mod spotify_account; 4 5 pub mod spotify_token;
+1
crates/scrobbler/src/xata/user.rs
··· 8 8 pub did: String, 9 9 pub handle: String, 10 10 pub avatar: String, 11 + pub shared_secret: Option<String>, 11 12 #[serde(with = "chrono::serde::ts_seconds")] 12 13 pub xata_createdat: DateTime<Utc>, 13 14 }
+45 -39
rockskyapi/rocksky-app-proxy/src/index.ts
··· 12 12 */ 13 13 14 14 const metadata = { 15 - redirect_uris: ['https://rocksky.app/oauth/callback'], 16 - response_types: ['code'], 17 - grant_types: ['authorization_code', 'refresh_token'], 18 - scope: 'atproto transition:generic', 19 - token_endpoint_auth_method: 'none', 20 - application_type: 'web', 21 - client_id: 'https://rocksky.app/client-metadata.json', 22 - client_name: 'AT Protocol Express App', 23 - client_uri: 'https://rocksky.app', 15 + redirect_uris: ["https://rocksky.app/oauth/callback"], 16 + response_types: ["code"], 17 + grant_types: ["authorization_code", "refresh_token"], 18 + scope: "atproto transition:generic", 19 + token_endpoint_auth_method: "none", 20 + application_type: "web", 21 + client_id: "https://rocksky.app/client-metadata.json", 22 + client_name: "AT Protocol Express App", 23 + client_uri: "https://rocksky.app", 24 24 dpop_bound_access_tokens: true, 25 25 }; 26 26 ··· 29 29 const url = new URL(request.url); 30 30 let redirectToApi = false; 31 31 32 - const API_ROUTES = ['/login', '/profile', '/token', '/now-playing', '/ws']; 32 + const API_ROUTES = ["/login", "/profile", "/token", "/now-playing", "/ws"]; 33 33 34 - console.log('Request URL:', url.pathname, url.pathname === '/client-metadata.json'); 34 + console.log( 35 + "Request URL:", 36 + url.pathname, 37 + url.pathname === "/client-metadata.json", 38 + ); 35 39 36 - if (url.pathname === '/client-metadata.json') { 40 + if (url.pathname === "/client-metadata.json") { 37 41 return Response.json(metadata); 38 42 } 39 43 40 44 if ( 41 45 API_ROUTES.includes(url.pathname) || 42 - url.pathname.startsWith('/oauth/callback') || 43 - url.pathname.startsWith('/users') || 44 - url.pathname.startsWith('/albums') || 45 - url.pathname.startsWith('/artists') || 46 - url.pathname.startsWith('/tracks') || 47 - url.pathname.startsWith('/scrobbles') || 48 - url.pathname.startsWith('/likes') || 49 - url.pathname.startsWith('/spotify') || 50 - url.pathname.startsWith('/dropbox/oauth/callback') || 51 - url.pathname.startsWith('/googledrive/oauth/callback') || 52 - url.pathname.startsWith('/dropbox/files') || 53 - url.pathname.startsWith('/dropbox/file') || 54 - url.pathname.startsWith('/googledrive/files') || 55 - url.pathname.startsWith('/dropbox/login') || 56 - url.pathname.startsWith('/googledrive/login') || 57 - url.pathname.startsWith('/dropbox/join') || 58 - url.pathname.startsWith('/googledrive/join') || 59 - url.pathname.startsWith('/search') || 60 - url.pathname.startsWith('/public/scrobbles') 46 + url.pathname.startsWith("/oauth/callback") || 47 + url.pathname.startsWith("/users") || 48 + url.pathname.startsWith("/albums") || 49 + url.pathname.startsWith("/artists") || 50 + url.pathname.startsWith("/tracks") || 51 + url.pathname.startsWith("/scrobbles") || 52 + url.pathname.startsWith("/likes") || 53 + url.pathname.startsWith("/spotify") || 54 + url.pathname.startsWith("/apikeys") || 55 + url.pathname.startsWith("/dropbox/oauth/callback") || 56 + url.pathname.startsWith("/googledrive/oauth/callback") || 57 + url.pathname.startsWith("/dropbox/files") || 58 + url.pathname.startsWith("/dropbox/file") || 59 + url.pathname.startsWith("/googledrive/files") || 60 + url.pathname.startsWith("/dropbox/login") || 61 + url.pathname.startsWith("/googledrive/login") || 62 + url.pathname.startsWith("/dropbox/join") || 63 + url.pathname.startsWith("/googledrive/join") || 64 + url.pathname.startsWith("/search") || 65 + url.pathname.startsWith("/public/scrobbles") 61 66 ) { 62 67 redirectToApi = true; 63 68 } 64 69 65 70 if (redirectToApi) { 66 71 const proxyUrl = new URL(request.url); 67 - proxyUrl.host = 'api.rocksky.app'; 68 - proxyUrl.hostname = 'api.rocksky.app'; 72 + proxyUrl.host = "api.rocksky.app"; 73 + proxyUrl.hostname = "api.rocksky.app"; 69 74 return fetch(proxyUrl, request) as any; 70 75 } 71 76 72 77 // check header if from mobile device, android or ios 73 - const userAgent = request.headers.get('user-agent'); 74 - const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; 78 + const userAgent = request.headers.get("user-agent"); 79 + const mobileRegex = 80 + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; 75 81 const isMobile = mobileRegex.test(userAgent!); 76 82 77 83 if (isMobile) { 78 84 const mobileUrl = new URL(request.url); 79 - mobileUrl.host = 'm.rocksky.app'; 80 - mobileUrl.hostname = 'm.rocksky.app'; 85 + mobileUrl.host = "m.rocksky.app"; 86 + mobileUrl.hostname = "m.rocksky.app"; 81 87 return fetch(mobileUrl, request); 82 88 } 83 89 84 90 const proxyUrl = new URL(request.url); 85 - proxyUrl.host = 'rocksky.pages.dev'; 86 - proxyUrl.hostname = 'rocksky.pages.dev'; 91 + proxyUrl.host = "rocksky.pages.dev"; 92 + proxyUrl.hostname = "rocksky.pages.dev"; 87 93 return fetch(proxyUrl, request) as any; 88 94 }, 89 95 } satisfies ExportedHandler<Env>;
+11
rockskyapi/rocksky-auth/.xata/migrations/.ledger
··· 253 253 mig_cvn1kqfo1tkgc98jggd0 254 254 mig_cvn1l8iglbhgau6ctujg 255 255 mig_cvrt0e5fchh2dtob8q4g 256 + mig_cvsn7hd1cltn5mtppc70 257 + mig_cvsn81l1cltn5mtppc80 258 + mig_cvsn8otfchh2dtob919g 259 + mig_cvsn9ad1cltn5mtppc90 260 + mig_cvsnc0tfchh2dtob91cg 261 + sql_58928c5fa822f6 262 + sql_525dccb0e69e3a 263 + sql_8b4842daf1e1a9 264 + mig_cvtnv0d1cltn5mtppjhg 265 + mig_cvtpkbno1tkgc98ji5h0 266 + mig_cvtpku7o1tkgc98ji5j0
+57
rockskyapi/rocksky-auth/.xata/migrations/mig_cvsn7hd1cltn5mtppc70.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "mig_cvsn7hd1cltn5mtppc70", 5 + "operations": [ 6 + { 7 + "create_table": { 8 + "name": "api_keys", 9 + "columns": [ 10 + { 11 + "name": "xata_createdat", 12 + "type": "timestamptz", 13 + "default": "now()" 14 + }, 15 + { 16 + "name": "xata_updatedat", 17 + "type": "timestamptz", 18 + "default": "now()" 19 + }, 20 + { 21 + "name": "xata_id", 22 + "type": "text", 23 + "check": { 24 + "name": "api_keys_xata_id_length_xata_id", 25 + "constraint": "length(\"xata_id\") < 256" 26 + }, 27 + "unique": true, 28 + "default": "'rec_' || xata_private.xid()" 29 + }, 30 + { 31 + "name": "xata_version", 32 + "type": "integer", 33 + "default": "0" 34 + } 35 + ] 36 + } 37 + }, 38 + { 39 + "sql": { 40 + "up": "ALTER TABLE \"api_keys\" REPLICA IDENTITY FULL", 41 + "onComplete": true 42 + } 43 + }, 44 + { 45 + "sql": { 46 + "up": "CREATE TRIGGER xata_maintain_metadata_trigger_pgroll\n BEFORE INSERT OR UPDATE\n ON \"api_keys\"\n FOR EACH ROW\n EXECUTE FUNCTION xata_private.maintain_metadata_trigger_pgroll()", 47 + "onComplete": true 48 + } 49 + } 50 + ] 51 + }, 52 + "migrationType": "pgroll", 53 + "name": "mig_cvsn7hd1cltn5mtppc70", 54 + "parent": "mig_cvrt0e5fchh2dtob8q4g", 55 + "schema": "public", 56 + "startedAt": "2025-04-11T19:55:50.295386Z" 57 + }
+25
rockskyapi/rocksky-auth/.xata/migrations/mig_cvsn81l1cltn5mtppc80.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "mig_cvsn81l1cltn5mtppc80", 5 + "operations": [ 6 + { 7 + "add_column": { 8 + "up": "''", 9 + "table": "api_keys", 10 + "column": { 11 + "name": "shared_secret", 12 + "type": "text", 13 + "unique": true, 14 + "comment": "" 15 + } 16 + } 17 + } 18 + ] 19 + }, 20 + "migrationType": "pgroll", 21 + "name": "mig_cvsn81l1cltn5mtppc80", 22 + "parent": "mig_cvsn7hd1cltn5mtppc70", 23 + "schema": "public", 24 + "startedAt": "2025-04-11T19:56:54.8875Z" 25 + }
+25
rockskyapi/rocksky-auth/.xata/migrations/mig_cvsn8otfchh2dtob919g.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "mig_cvsn8otfchh2dtob919g", 5 + "operations": [ 6 + { 7 + "add_column": { 8 + "up": "''", 9 + "table": "api_keys", 10 + "column": { 11 + "name": "api_key", 12 + "type": "text", 13 + "unique": true, 14 + "comment": "" 15 + } 16 + } 17 + } 18 + ] 19 + }, 20 + "migrationType": "pgroll", 21 + "name": "mig_cvsn8otfchh2dtob919g", 22 + "parent": "mig_cvsn81l1cltn5mtppc80", 23 + "schema": "public", 24 + "startedAt": "2025-04-11T19:58:27.763431Z" 25 + }
+30
rockskyapi/rocksky-auth/.xata/migrations/mig_cvsn9ad1cltn5mtppc90.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "mig_cvsn9ad1cltn5mtppc90", 5 + "operations": [ 6 + { 7 + "add_column": { 8 + "up": "''", 9 + "table": "api_keys", 10 + "column": { 11 + "name": "user_id", 12 + "type": "text", 13 + "comment": "{\"xata.link\":\"users\"}", 14 + "references": { 15 + "name": "user_id_link", 16 + "table": "users", 17 + "column": "xata_id", 18 + "on_delete": "CASCADE" 19 + } 20 + } 21 + } 22 + } 23 + ] 24 + }, 25 + "migrationType": "pgroll", 26 + "name": "mig_cvsn9ad1cltn5mtppc90", 27 + "parent": "mig_cvsn8otfchh2dtob919g", 28 + "schema": "public", 29 + "startedAt": "2025-04-11T19:59:37.377207Z" 30 + }
+24
rockskyapi/rocksky-auth/.xata/migrations/mig_cvsnc0tfchh2dtob91cg.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "mig_cvsnc0tfchh2dtob91cg", 5 + "operations": [ 6 + { 7 + "add_column": { 8 + "table": "api_keys", 9 + "column": { 10 + "name": "description", 11 + "type": "text", 12 + "comment": "", 13 + "nullable": true 14 + } 15 + } 16 + } 17 + ] 18 + }, 19 + "migrationType": "pgroll", 20 + "name": "mig_cvsnc0tfchh2dtob91cg", 21 + "parent": "mig_cvsn9ad1cltn5mtppc90", 22 + "schema": "public", 23 + "startedAt": "2025-04-11T20:05:24.469571Z" 24 + }
+24
rockskyapi/rocksky-auth/.xata/migrations/mig_cvtnv0d1cltn5mtppjhg.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "mig_cvtnv0d1cltn5mtppjhg", 5 + "operations": [ 6 + { 7 + "add_column": { 8 + "table": "api_keys", 9 + "column": { 10 + "name": "enabled", 11 + "type": "bool", 12 + "comment": "", 13 + "default": "'true'" 14 + } 15 + } 16 + } 17 + ] 18 + }, 19 + "migrationType": "pgroll", 20 + "name": "mig_cvtnv0d1cltn5mtppjhg", 21 + "parent": "sql_8b4842daf1e1a9", 22 + "schema": "public", 23 + "startedAt": "2025-04-13T09:10:25.536065Z" 24 + }
+24
rockskyapi/rocksky-auth/.xata/migrations/mig_cvtpkbno1tkgc98ji5h0.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "mig_cvtpkbno1tkgc98ji5h0", 5 + "operations": [ 6 + { 7 + "add_column": { 8 + "up": "''", 9 + "table": "api_keys", 10 + "column": { 11 + "name": "name", 12 + "type": "text", 13 + "comment": "" 14 + } 15 + } 16 + } 17 + ] 18 + }, 19 + "migrationType": "pgroll", 20 + "name": "mig_cvtpkbno1tkgc98ji5h0", 21 + "parent": "mig_cvtnv0d1cltn5mtppjhg", 22 + "schema": "public", 23 + "startedAt": "2025-04-13T11:04:14.578022Z" 24 + }
+24
rockskyapi/rocksky-auth/.xata/migrations/mig_cvtpku7o1tkgc98ji5j0.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "mig_cvtpku7o1tkgc98ji5j0", 5 + "operations": [ 6 + { 7 + "alter_column": { 8 + "up": "\"name\"", 9 + "down": "\"name\"", 10 + "table": "api_keys", 11 + "column": "name", 12 + "unique": { 13 + "name": "api_keys_name_unique" 14 + } 15 + } 16 + } 17 + ] 18 + }, 19 + "migrationType": "pgroll", 20 + "name": "mig_cvtpku7o1tkgc98ji5j0", 21 + "parent": "mig_cvtpkbno1tkgc98ji5h0", 22 + "schema": "public", 23 + "startedAt": "2025-04-13T11:05:29.131622Z" 24 + }
+18
rockskyapi/rocksky-auth/.xata/migrations/sql_525dccb0e69e3a.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "sql_525dccb0e69e3a", 5 + "operations": [ 6 + { 7 + "sql": { 8 + "up": "CREATE INDEX idx_tracks_lower_artist ON tracks USING btree (lower(artist))" 9 + } 10 + } 11 + ] 12 + }, 13 + "migrationType": "inferred", 14 + "name": "sql_525dccb0e69e3a", 15 + "parent": "sql_58928c5fa822f6", 16 + "schema": "public", 17 + "startedAt": "2025-04-12T15:29:22.222122Z" 18 + }
+18
rockskyapi/rocksky-auth/.xata/migrations/sql_58928c5fa822f6.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "sql_58928c5fa822f6", 5 + "operations": [ 6 + { 7 + "sql": { 8 + "up": "CREATE INDEX idx_tracks_lower_title ON tracks USING btree (lower(title))" 9 + } 10 + } 11 + ] 12 + }, 13 + "migrationType": "inferred", 14 + "name": "sql_58928c5fa822f6", 15 + "parent": "mig_cvsnc0tfchh2dtob91cg", 16 + "schema": "public", 17 + "startedAt": "2025-04-12T15:28:47.092727Z" 18 + }
+18
rockskyapi/rocksky-auth/.xata/migrations/sql_8b4842daf1e1a9.json
··· 1 + { 2 + "done": true, 3 + "migration": { 4 + "name": "sql_8b4842daf1e1a9", 5 + "operations": [ 6 + { 7 + "sql": { 8 + "up": "CREATE INDEX idx_tracks_lower_album_artist ON tracks USING btree (lower(album_artist))" 9 + } 10 + } 11 + ] 12 + }, 13 + "migrationType": "inferred", 14 + "name": "sql_8b4842daf1e1a9", 15 + "parent": "sql_525dccb0e69e3a", 16 + "schema": "public", 17 + "startedAt": "2025-04-12T15:29:33.3105Z" 18 + }
+10
rockskyapi/rocksky-auth/bun.lock
··· 21 21 "better-sqlite3": "^11.8.1", 22 22 "chalk": "^5.4.1", 23 23 "chanfana": "^2.0.2", 24 + "dayjs": "^1.11.13", 24 25 "dotenv": "^16.4.7", 25 26 "drizzle-orm": "^0.39.3", 26 27 "dropbox": "^10.34.0", ··· 43 44 }, 44 45 "devDependencies": { 45 46 "@types/node": "^22.13.0", 47 + "@types/ramda": "^0.30.2", 46 48 "@types/service-worker-mock": "^2.0.1", 47 49 "pkgroll": "^2.6.1", 48 50 "tsx": "^4.19.2", ··· 264 266 265 267 "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], 266 268 269 + "@types/ramda": ["@types/ramda@0.30.2", "", { "dependencies": { "types-ramda": "^0.30.1" } }, "sha512-PyzHvjCalm2BRYjAU6nIB3TprYwMNOUY/7P/N8bSzp9W/yM2YrtGtAnnVtaCNSeOZ8DzKyFDvaqQs7LnWwwmBA=="], 270 + 267 271 "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], 268 272 269 273 "@types/service-worker-mock": ["@types/service-worker-mock@2.0.4", "", {}, "sha512-MEBT2eiqYfhxjqYm/oAf2AvKLbPTPwJJAYrMdheKnGyz1yG9XBRfxCzi93h27qpSvI7jOYfXqFLVMLBXFDqo4A=="], ··· 371 375 "cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="], 372 376 373 377 "crossws": ["crossws@0.3.3", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-/71DJT3xJlqSnBr83uGJesmVHSzZEvgxHt/fIKxBAAngqMHmnBWQNxCphVxxJ2XL3xleu5+hJD6IQ3TglBedcw=="], 378 + 379 + "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], 374 380 375 381 "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 376 382 ··· 832 838 833 839 "ts-morph": ["ts-morph@16.0.0", "", { "dependencies": { "@ts-morph/common": "~0.17.0", "code-block-writer": "^11.0.3" } }, "sha512-jGNF0GVpFj0orFw55LTsQxVYEUOCWBAbR5Ls7fTYE5pQsbW18ssTb/6UXx/GYAEjS+DQTp8VoTw0vqYMiaaQuw=="], 834 840 841 + "ts-toolbelt": ["ts-toolbelt@9.6.0", "", {}, "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w=="], 842 + 835 843 "tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], 836 844 837 845 "tsx": ["tsx@4.19.2", "", { "dependencies": { "esbuild": "~0.23.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g=="], ··· 841 849 "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], 842 850 843 851 "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], 852 + 853 + "types-ramda": ["types-ramda@0.30.1", "", { "dependencies": { "ts-toolbelt": "^9.6.0" } }, "sha512-1HTsf5/QVRmLzcGfldPFvkVsAdi1db1BBKzi7iW3KBUlOICg/nKnFS+jGqDJS3YD8VsWbAh7JiHeBvbsw8RPxA=="], 844 854 845 855 "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], 846 856
+2
rockskyapi/rocksky-auth/package.json
··· 27 27 "better-sqlite3": "^11.8.1", 28 28 "chalk": "^5.4.1", 29 29 "chanfana": "^2.0.2", 30 + "dayjs": "^1.11.13", 30 31 "dotenv": "^16.4.7", 31 32 "drizzle-orm": "^0.39.3", 32 33 "dropbox": "^10.34.0", ··· 49 50 }, 50 51 "devDependencies": { 51 52 "@types/node": "^22.13.0", 53 + "@types/ramda": "^0.30.2", 52 54 "@types/service-worker-mock": "^2.0.1", 53 55 "pkgroll": "^2.6.1", 54 56 "tsx": "^4.19.2"
+152
rockskyapi/rocksky-auth/src/apikeys/app.ts
··· 1 + import { equals } from "@xata.io/client"; 2 + import { ctx } from "context"; 3 + import { and, eq } from "drizzle-orm"; 4 + import { Hono } from "hono"; 5 + import jwt from "jsonwebtoken"; 6 + import { env } from "lib/env"; 7 + import crypto from "node:crypto"; 8 + import * as R from "ramda"; 9 + import tables from "schema"; 10 + import { apiKeySchema } from "types/apikey"; 11 + 12 + const app = new Hono(); 13 + 14 + app.get("/", async (c) => { 15 + const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 16 + 17 + if (!bearer || bearer === "null") { 18 + c.status(401); 19 + return c.text("Unauthorized"); 20 + } 21 + 22 + const { did } = jwt.verify(bearer, env.JWT_SECRET); 23 + 24 + const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 25 + if (!user) { 26 + c.status(401); 27 + return c.text("Unauthorized"); 28 + } 29 + 30 + const size = +c.req.query("size") || 20; 31 + const offset = +c.req.query("offset") || 0; 32 + 33 + const apikeys = await ctx.db 34 + .select() 35 + .from(tables.apiKeys) 36 + .where(eq(tables.apiKeys.userId, user.xata_id)) 37 + .limit(size) 38 + .offset(offset) 39 + .execute(); 40 + 41 + return c.json(apikeys.map((x) => R.omit(["userId"])(x))); 42 + }); 43 + 44 + app.post("/", async (c) => { 45 + const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 46 + 47 + if (!bearer || bearer === "null") { 48 + c.status(401); 49 + return c.text("Unauthorized"); 50 + } 51 + 52 + const { did } = jwt.verify(bearer, env.JWT_SECRET); 53 + 54 + const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 55 + if (!user) { 56 + c.status(401); 57 + return c.text("Unauthorized"); 58 + } 59 + 60 + const body = await c.req.json(); 61 + const parsed = apiKeySchema.safeParse(body); 62 + 63 + if (parsed.error) { 64 + c.status(400); 65 + return c.text("Invalid api key data: " + parsed.error.message); 66 + } 67 + const newApiKey = parsed.data; 68 + 69 + const api_key = crypto.randomBytes(16).toString("hex"); 70 + const shared_secret = crypto.randomBytes(16).toString("hex"); 71 + 72 + const record = await ctx.client.db.api_keys.create({ 73 + ...newApiKey, 74 + api_key, 75 + shared_secret, 76 + user_id: user.xata_id, 77 + }); 78 + 79 + return c.json({ 80 + id: record.xata_id, 81 + name: record.name, 82 + description: record.description, 83 + api_key: record.api_key, 84 + shared_secret: record.shared_secret, 85 + }); 86 + }); 87 + 88 + app.put("/:id", async (c) => { 89 + const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 90 + 91 + if (!bearer || bearer === "null") { 92 + c.status(401); 93 + return c.text("Unauthorized"); 94 + } 95 + 96 + const { did } = jwt.verify(bearer, env.JWT_SECRET); 97 + 98 + const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 99 + if (!user) { 100 + c.status(401); 101 + return c.text("Unauthorized"); 102 + } 103 + 104 + const data = await c.req.json(); 105 + const id = c.req.param("id"); 106 + 107 + const record = await ctx.db 108 + .update(tables.apiKeys) 109 + .set(data) 110 + .where( 111 + and(eq(tables.apiKeys.id, id), eq(tables.apiKeys.userId, user.xata_id)) 112 + ) 113 + .execute(); 114 + 115 + return c.json({ 116 + id: record.xata_id, 117 + name: record.name, 118 + description: record.description, 119 + api_key: record.api_key, 120 + shared_secret: record.shared_secret, 121 + }); 122 + }); 123 + 124 + app.delete("/:id", async (c) => { 125 + const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 126 + 127 + if (!bearer || bearer === "null") { 128 + c.status(401); 129 + return c.text("Unauthorized"); 130 + } 131 + 132 + const { did } = jwt.verify(bearer, env.JWT_SECRET); 133 + 134 + const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 135 + if (!user) { 136 + c.status(401); 137 + return c.text("Unauthorized"); 138 + } 139 + 140 + const id = c.req.param("id"); 141 + 142 + await ctx.db 143 + .delete(tables.apiKeys) 144 + .where( 145 + and(eq(tables.apiKeys.id, id), eq(tables.apiKeys.userId, user.xata_id)) 146 + ) 147 + .execute(); 148 + 149 + return c.json({ success: true }); 150 + }); 151 + 152 + export default app;
+4 -1
rockskyapi/rocksky-auth/src/bsky/app.ts
··· 66 66 cli = ctx.kv.get(`cli:${handle}`); 67 67 ctx.kv.delete(`cli:${handle}`); 68 68 69 - const token = jwt.sign({ did }, env.JWT_SECRET); 69 + const token = jwt.sign( 70 + { did, exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7 }, 71 + env.JWT_SECRET 72 + ); 70 73 ctx.kv.set(did, token); 71 74 } catch (err) { 72 75 console.error({ err }, "oauth callback failed");
+3
rockskyapi/rocksky-auth/src/index.ts
··· 16 16 import { saveTrack } from "tracks/tracks.service"; 17 17 import { trackSchema } from "types/track"; 18 18 import handleWebsocket from "websocket/handler"; 19 + import apikeys from "./apikeys/app"; 19 20 import bsky from "./bsky/app"; 20 21 import dropbox from "./dropbox/app"; 21 22 import googledrive from "./googledrive/app"; ··· 38 39 app.route("/dropbox", dropbox); 39 40 40 41 app.route("/googledrive", googledrive); 42 + 43 + app.route("/apikeys", apikeys); 41 44 42 45 app.get("/ws", upgradeWebSocket(handleWebsocket)); 43 46
+8 -1
rockskyapi/rocksky-auth/src/nowplaying/nowplaying.service.ts
··· 3 3 import { equals, SelectedPick } from "@xata.io/client"; 4 4 import { Context } from "context"; 5 5 import { createHash } from "crypto"; 6 + import dayjs from "dayjs"; 6 7 import * as Album from "lexicon/types/app/rocksky/album"; 7 8 import * as Artist from "lexicon/types/app/rocksky/artist"; 8 9 import * as Scrobble from "lexicon/types/app/rocksky/scrobble"; ··· 225 226 copyrightMessage: !!track.copyrightMessage 226 227 ? track.copyrightMessage 227 228 : undefined, 228 - createdAt: new Date().toISOString(), 229 + // if track.timestamp is not null, set it to the timestamp 230 + createdAt: track.timestamp 231 + ? dayjs.unix(track.timestamp).toISOString() 232 + : new Date().toISOString(), 229 233 }; 230 234 231 235 if (!Scrobble.validateRecord(record).success) { ··· 578 582 album_id, 579 583 artist_id, 580 584 uri: scrobbleUri, 585 + xata_createdat: track.timestamp 586 + ? dayjs.unix(track.timestamp).toDate() 587 + : undefined, 581 588 }); 582 589 583 590 await publishScrobble(ctx, scrobble.xata_id);
+18
rockskyapi/rocksky-auth/src/schema/api-keys.ts
··· 1 + import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 2 + import users from "./users"; 3 + 4 + const apiKeys = pgTable("api_keys", { 5 + id: text("xata_id").primaryKey(), 6 + name: text("name").notNull(), 7 + apiKey: text("api_key").notNull(), 8 + sharedSecret: text("shared_secret").notNull(), 9 + description: text("description"), 10 + enabled: boolean("enabled").default(true).notNull(), 11 + userId: text("user_id") 12 + .notNull() 13 + .references(() => users.id), 14 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 15 + updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 16 + }); 17 + 18 + export default apiKeys;
+2
rockskyapi/rocksky-auth/src/schema/index.ts
··· 1 1 import albums from "./albums"; 2 + import apiKeys from "./api-keys"; 2 3 import artists from "./artists"; 3 4 import playlistTracks from "./playlist-tracks"; 4 5 import playlists from "./playlists"; ··· 22 23 shoutReports, 23 24 playlists, 24 25 playlistTracks, 26 + apiKeys, 25 27 };
+9
rockskyapi/rocksky-auth/src/types/apikey.ts
··· 1 + import z from "zod"; 2 + 3 + export const apiKeySchema = z.object({ 4 + name: z.string().nonempty(), 5 + description: z.string().optional().nullable(), 6 + enabled: z.boolean().optional(), 7 + }); 8 + 9 + export type ApiKey = z.infer<typeof apiKeySchema>;
+1
rockskyapi/rocksky-auth/src/types/track.ts
··· 24 24 label: z.string().optional().nullable(), 25 25 artistPicture: z.string().optional().nullable(), 26 26 spotifyLink: z.string().optional().nullable(), 27 + timestamp: z.number().optional().nullable(), 27 28 }); 28 29 29 30 export type Track = z.infer<typeof trackSchema>;
+122
rockskyapi/rocksky-auth/src/xata.ts
··· 342 342 ], 343 343 }, 344 344 { 345 + name: "api_keys", 346 + checkConstraints: { 347 + api_keys_xata_id_length_xata_id: { 348 + name: "api_keys_xata_id_length_xata_id", 349 + columns: ["xata_id"], 350 + definition: "CHECK ((length(xata_id) < 256))", 351 + }, 352 + }, 353 + foreignKeys: { 354 + user_id_link: { 355 + name: "user_id_link", 356 + columns: ["user_id"], 357 + referencedTable: "users", 358 + referencedColumns: ["xata_id"], 359 + onDelete: "CASCADE", 360 + }, 361 + }, 362 + primaryKey: [], 363 + uniqueConstraints: { 364 + _pgroll_new_api_keys_xata_id_key: { 365 + name: "_pgroll_new_api_keys_xata_id_key", 366 + columns: ["xata_id"], 367 + }, 368 + api_keys__pgroll_new_api_key_key: { 369 + name: "api_keys__pgroll_new_api_key_key", 370 + columns: ["api_key"], 371 + }, 372 + api_keys__pgroll_new_shared_secret_key: { 373 + name: "api_keys__pgroll_new_shared_secret_key", 374 + columns: ["shared_secret"], 375 + }, 376 + api_keys_name_unique: { name: "api_keys_name_unique", columns: ["name"] }, 377 + }, 378 + columns: [ 379 + { 380 + name: "api_key", 381 + type: "text", 382 + notNull: true, 383 + unique: true, 384 + defaultValue: null, 385 + comment: "", 386 + }, 387 + { 388 + name: "description", 389 + type: "text", 390 + notNull: false, 391 + unique: false, 392 + defaultValue: null, 393 + comment: "", 394 + }, 395 + { 396 + name: "enabled", 397 + type: "bool", 398 + notNull: true, 399 + unique: false, 400 + defaultValue: "true", 401 + comment: "", 402 + }, 403 + { 404 + name: "name", 405 + type: "text", 406 + notNull: true, 407 + unique: true, 408 + defaultValue: null, 409 + comment: "", 410 + }, 411 + { 412 + name: "shared_secret", 413 + type: "text", 414 + notNull: true, 415 + unique: true, 416 + defaultValue: null, 417 + comment: "", 418 + }, 419 + { 420 + name: "user_id", 421 + type: "link", 422 + link: { table: "users" }, 423 + notNull: true, 424 + unique: false, 425 + defaultValue: null, 426 + comment: '{"xata.link":"users"}', 427 + }, 428 + { 429 + name: "xata_createdat", 430 + type: "datetime", 431 + notNull: true, 432 + unique: false, 433 + defaultValue: "now()", 434 + comment: "", 435 + }, 436 + { 437 + name: "xata_id", 438 + type: "text", 439 + notNull: true, 440 + unique: true, 441 + defaultValue: "('rec_'::text || (xata_private.xid())::text)", 442 + comment: "", 443 + }, 444 + { 445 + name: "xata_updatedat", 446 + type: "datetime", 447 + notNull: true, 448 + unique: false, 449 + defaultValue: "now()", 450 + comment: "", 451 + }, 452 + { 453 + name: "xata_version", 454 + type: "int", 455 + notNull: true, 456 + unique: false, 457 + defaultValue: "0", 458 + comment: "", 459 + }, 460 + ], 461 + }, 462 + { 345 463 name: "artist_albums", 346 464 checkConstraints: { 347 465 artist_albums_xata_id_length_xata_id: { ··· 4294 4412 export type Albums = InferredTypes["albums"]; 4295 4413 export type AlbumsRecord = Albums & XataRecord; 4296 4414 4415 + export type ApiKeys = InferredTypes["api_keys"]; 4416 + export type ApiKeysRecord = ApiKeys & XataRecord; 4417 + 4297 4418 export type ArtistAlbums = InferredTypes["artist_albums"]; 4298 4419 export type ArtistAlbumsRecord = ArtistAlbums & XataRecord; 4299 4420 ··· 4418 4539 album_tags: AlbumTagsRecord; 4419 4540 album_tracks: AlbumTracksRecord; 4420 4541 albums: AlbumsRecord; 4542 + api_keys: ApiKeysRecord; 4421 4543 artist_albums: ArtistAlbumsRecord; 4422 4544 artist_tags: ArtistTagsRecord; 4423 4545 artist_tracks: ArtistTracksRecord;
+5
rockskyweb/bun.lock
··· 28 28 "@vitest/ui": "^3.0.4", 29 29 "axios": "^1.7.9", 30 30 "baseui": "15.0.0", 31 + "copy-to-clipboard": "^3.3.3", 31 32 "date-fns": "^4.1.0", 32 33 "dayjs": "^1.11.13", 33 34 "i18next": "^24.2.2", ··· 642 643 "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], 643 644 644 645 "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], 646 + 647 + "copy-to-clipboard": ["copy-to-clipboard@3.3.3", "", { "dependencies": { "toggle-selection": "^1.0.6" } }, "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA=="], 645 648 646 649 "core-js": ["core-js@3.41.0", "", {}, "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA=="], 647 650 ··· 1300 1303 "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], 1301 1304 1302 1305 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 1306 + 1307 + "toggle-selection": ["toggle-selection@1.0.6", "", {}, "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="], 1303 1308 1304 1309 "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], 1305 1310
+1
rockskyweb/package.json
··· 38 38 "@vitest/ui": "^3.0.4", 39 39 "axios": "^1.7.9", 40 40 "baseui": "15.0.0", 41 + "copy-to-clipboard": "^3.3.3", 41 42 "date-fns": "^4.1.0", 42 43 "dayjs": "^1.11.13", 43 44 "i18next": "^24.2.2",
+2
rockskyweb/src/App.tsx
··· 1 1 import { BrowserRouter, Route, Routes } from "react-router-dom"; 2 2 import AlbumPage from "./pages/album"; 3 + import ApiKeys from "./pages/apikeys"; 3 4 import ArtistPage from "./pages/artist"; 4 5 import Dropbox from "./pages/dropbox"; 5 6 import DropboxWithId from "./pages/dropbox/DropboxWithId"; ··· 50 51 path="/googledrive/:id" 51 52 element={<GoogleDriveWithId key={window.location.pathname} />} 52 53 /> 54 + <Route path="/apikeys" element={<ApiKeys />} /> 53 55 <Route path="/loading" element={<Loading />} /> 54 56 </Routes> 55 57 </BrowserRouter>
+48
rockskyweb/src/hooks/useApikey.tsx
··· 1 + import axios from "axios"; 2 + import { API_URL } from "../consts"; 3 + import { ApiKey } from "../types/apikey"; 4 + 5 + function useApikey() { 6 + const headers = { 7 + authorization: `Bearer ${localStorage.getItem("token")}`, 8 + }; 9 + 10 + const createApiKey = async (name: string, description?: string) => { 11 + return await axios.post<ApiKey>( 12 + `${API_URL}/apikeys`, 13 + { name, description }, 14 + { headers } 15 + ); 16 + }; 17 + 18 + const getApiKeys = async (offset = 0, size = 20) => { 19 + return await axios.get<ApiKey[]>(`${API_URL}/apikeys`, { 20 + headers, 21 + params: { 22 + offset, 23 + size, 24 + }, 25 + }); 26 + }; 27 + 28 + const deleteApiKey = async (id: string) => { 29 + return await axios.delete(`${API_URL}/apikeys/${id}`, { headers }); 30 + }; 31 + 32 + const updateApiKey = async ( 33 + id: string, 34 + enabled: boolean, 35 + name?: string, 36 + description?: string 37 + ) => { 38 + return await axios.put<ApiKey>( 39 + `${API_URL}/apikeys/${id}`, 40 + { name, description, enabled }, 41 + { headers } 42 + ); 43 + }; 44 + 45 + return { createApiKey, getApiKeys, deleteApiKey, updateApiKey }; 46 + } 47 + 48 + export default useApikey;
+115 -100
rockskyweb/src/layouts/Main.tsx
··· 51 51 } 52 52 `; 53 53 54 - function Main({ children }: { children: React.ReactNode }) { 54 + export type MainProps = { 55 + children: React.ReactNode; 56 + withRightPane?: boolean; 57 + }; 58 + 59 + function Main(props: MainProps) { 60 + const { children } = props; 61 + const withRightPane = props.withRightPane ?? true; 55 62 const [handle, setHandle] = useState(""); 56 63 const { search } = useLocation(); 57 64 const jwt = localStorage.getItem("token"); ··· 121 128 }*/ 122 129 123 130 // window.location.href = `${API_URL}/login?handle=${handle}`; 131 + 132 + if (API_URL.includes("localhost")) { 133 + window.location.href = `${API_URL}/login?handle=${handle}`; 134 + return; 135 + } 136 + 124 137 window.location.href = `https://rocksky.pages.dev/loading?handle=${handle}`; 125 138 }; 126 139 ··· 137 150 }, 138 151 }} 139 152 /> 140 - <Flex> 153 + <Flex style={{ width: withRightPane ? "770px" : "1090px" }}> 141 154 <Navbar /> 142 155 <div 143 156 style={{ ··· 147 160 {children} 148 161 </div> 149 162 </Flex> 150 - <RightPane style={{ position: "relative", width: 300 }}> 151 - <div 152 - style={{ 153 - position: "fixed", 154 - top: 100, 155 - width: 300, 156 - padding: 20, 157 - backgroundColor: "#fff", 158 - }} 159 - > 160 - <div style={{ marginBottom: 30 }}> 161 - <Search /> 162 - </div> 163 - {jwt && profile && !profile.spotifyConnected && <SpotifyLogin />} 164 - {jwt && profile && <CloudDrive />} 165 - {!jwt && ( 166 - <div style={{ marginTop: 40 }}> 167 - <div style={{ marginBottom: 20 }}> 168 - <div style={{ marginBottom: 15 }}> 169 - <LabelMedium>Bluesky handle</LabelMedium> 163 + {withRightPane && ( 164 + <RightPane style={{ position: "relative", width: 300 }}> 165 + <div 166 + style={{ 167 + position: "fixed", 168 + top: 100, 169 + width: 300, 170 + padding: 20, 171 + backgroundColor: "#fff", 172 + }} 173 + > 174 + <div style={{ marginBottom: 30 }}> 175 + <Search /> 176 + </div> 177 + {jwt && profile && !profile.spotifyConnected && <SpotifyLogin />} 178 + {jwt && profile && <CloudDrive />} 179 + {!jwt && ( 180 + <div style={{ marginTop: 40 }}> 181 + <div style={{ marginBottom: 20 }}> 182 + <div style={{ marginBottom: 15 }}> 183 + <LabelMedium>Bluesky handle</LabelMedium> 184 + </div> 185 + <Input 186 + name="handle" 187 + startEnhancer={<div style={{ color: "#42576ca6" }}>@</div>} 188 + placeholder="<username>.bsky.social" 189 + value={handle} 190 + onChange={(e) => setHandle(e.target.value)} 191 + /> 170 192 </div> 171 - <Input 172 - name="handle" 173 - startEnhancer={<div style={{ color: "#42576ca6" }}>@</div>} 174 - placeholder="<username>.bsky.social" 175 - value={handle} 176 - onChange={(e) => setHandle(e.target.value)} 177 - /> 178 - </div> 179 - <Button 180 - onClick={onLogin} 181 - overrides={{ 182 - BaseButton: { 183 - style: { 184 - width: "100%", 185 - backgroundColor: "#ff2876", 186 - ":hover": { 193 + <Button 194 + onClick={onLogin} 195 + overrides={{ 196 + BaseButton: { 197 + style: { 198 + width: "100%", 187 199 backgroundColor: "#ff2876", 188 - }, 189 - ":focus": { 190 - backgroundColor: "#ff2876", 200 + ":hover": { 201 + backgroundColor: "#ff2876", 202 + }, 203 + ":focus": { 204 + backgroundColor: "#ff2876", 205 + }, 191 206 }, 192 207 }, 193 - }, 194 - }} 195 - > 196 - Sign In 197 - </Button> 198 - <LabelMedium 199 - marginTop={"20px"} 200 - style={{ 201 - textAlign: "center", 202 - color: "#42576ca6", 203 - }} 204 - > 205 - Don't have an account? 206 - </LabelMedium> 207 - <div 208 - style={{ 209 - color: "#42576ca6", 210 - textAlign: "center", 211 - }} 212 - > 213 - <a 214 - href="https://bsky.app" 208 + }} 209 + > 210 + Sign In 211 + </Button> 212 + <LabelMedium 213 + marginTop={"20px"} 214 + style={{ 215 + textAlign: "center", 216 + color: "#42576ca6", 217 + }} 218 + > 219 + Don't have an account? 220 + </LabelMedium> 221 + <div 215 222 style={{ 216 - color: "#ff2876", 217 - textDecoration: "none", 218 - cursor: "pointer", 223 + color: "#42576ca6", 219 224 textAlign: "center", 220 225 }} 221 - target="_blank" 222 226 > 223 - Sign up for Bluesky 224 - </a>{" "} 225 - to create one now! 227 + <a 228 + href="https://bsky.app" 229 + style={{ 230 + color: "#ff2876", 231 + textDecoration: "none", 232 + cursor: "pointer", 233 + textAlign: "center", 234 + }} 235 + target="_blank" 236 + > 237 + Sign up for Bluesky 238 + </a>{" "} 239 + to create one now! 240 + </div> 226 241 </div> 242 + )} 243 + 244 + <div style={{ marginTop: 40 }}> 245 + <ScrobblesAreaChart /> 227 246 </div> 228 - )} 229 - 230 - <div style={{ marginTop: 40 }}> 231 - <ScrobblesAreaChart /> 247 + <ExternalLinks /> 248 + <div style={{ marginTop: 40, display: "inline-flex" }}> 249 + <Link 250 + href="https://docs.rocksky.app/introduction-918639m0" 251 + target="_blank" 252 + style={{ marginRight: 10 }} 253 + > 254 + About 255 + </Link> 256 + <Link 257 + href="https://docs.rocksky.app/faq-918661m0" 258 + target="_blank" 259 + style={{ marginRight: 10 }} 260 + > 261 + FAQ 262 + </Link> 263 + <Link 264 + href="https://doc.rocksky.app/" 265 + target="_blank" 266 + style={{ marginRight: 10 }} 267 + > 268 + API Docs 269 + </Link> 270 + </div> 232 271 </div> 233 - <ExternalLinks /> 234 - <div style={{ marginTop: 40, display: "inline-flex" }}> 235 - <Link 236 - href="https://docs.rocksky.app/introduction-918639m0" 237 - target="_blank" 238 - style={{ marginRight: 10 }} 239 - > 240 - About 241 - </Link> 242 - <Link 243 - href="https://docs.rocksky.app/faq-918661m0" 244 - target="_blank" 245 - style={{ marginRight: 10 }} 246 - > 247 - FAQ 248 - </Link> 249 - <Link 250 - href="https://doc.rocksky.app/" 251 - target="_blank" 252 - style={{ marginRight: 10 }} 253 - > 254 - API Docs 255 - </Link> 256 - </div> 257 - </div> 258 - </RightPane> 272 + </RightPane> 273 + )} 259 274 <StickyPlayer /> 260 275 </Container> 261 276 );
+7
rockskyweb/src/layouts/Navbar/Navbar.tsx
··· 72 72 label: <LabelMedium>Profile</LabelMedium>, 73 73 }, 74 74 { 75 + id: "api-applications", 76 + label: <LabelMedium>API Applications</LabelMedium>, 77 + }, 78 + { 75 79 id: "signout", 76 80 label: <LabelMedium>Sign out</LabelMedium>, 77 81 }, ··· 80 84 switch (item.id) { 81 85 case "profile": 82 86 navigate(`/profile/${profile.handle}`); 87 + break; 88 + case "api-applications": 89 + navigate("/apikeys"); 83 90 break; 84 91 case "signout": 85 92 setProfile(null);
+14 -3
rockskyweb/src/layouts/Search/Search.tsx
··· 84 84 {results.map((item: any) => ( 85 85 <> 86 86 {item.table === "users" && ( 87 - <Link to={`/profile/${item.record.handle}`}> 87 + <Link 88 + to={`/profile/${item.record.handle}`} 89 + key={item.record.xata_id} 90 + > 88 91 <div 89 92 style={{ 90 93 display: "flex", ··· 92 95 }} 93 96 > 94 97 <img 98 + key={item.record.did} 95 99 src={item.record.avatar} 96 100 alt={item.record.display_name} 97 101 style={{ ··· 134 138 {item.record.uri && 135 139 (item.record.name || item.record.title) && 136 140 item.record.type !== "users" && ( 137 - <Link to={`/${item.record.uri?.split("at://")[1]}`}> 141 + <Link 142 + to={`/${item.record.uri?.split("at://")[1]}`} 143 + key={item.record.xata_id} 144 + > 138 145 <div 139 - key={item.id} 146 + key={item.record.xata_id} 140 147 style={{ 141 148 height: 64, 142 149 display: "flex", ··· 147 154 {item.table === "artists" && 148 155 item.record.picture && ( 149 156 <img 157 + key={item.record.xata_id} 150 158 src={item.record.picture} 151 159 alt={item.record.name} 152 160 style={{ ··· 160 168 {item.table === "artists" && 161 169 !item.record.picture && ( 162 170 <div 171 + key={item.record.xata_id} 163 172 style={{ 164 173 width: 50, 165 174 height: 50, ··· 185 194 {item.table === "albums" && 186 195 item.record.album_art && ( 187 196 <img 197 + key={item.record.xata_id} 188 198 src={item.record.album_art} 189 199 alt={item.record.title} 190 200 style={{ ··· 225 235 {item.table === "tracks" && 226 236 item.record.album_art && ( 227 237 <img 238 + key={item.record.xata_id} 228 239 src={item.record.album_art} 229 240 alt={item.record.title} 230 241 style={{
+295
rockskyweb/src/pages/apikeys/ApiKeys.tsx
··· 1 + import { zodResolver } from "@hookform/resolvers/zod"; 2 + import { Plus } from "@styled-icons/evaicons-solid"; 3 + import { Copy, Trash } from "@styled-icons/ionicons-outline"; 4 + import { Button } from "baseui/button"; 5 + import { Input } from "baseui/input"; 6 + import { Modal, ModalBody, ModalFooter, ModalHeader } from "baseui/modal"; 7 + import { TableBuilder, TableBuilderColumn } from "baseui/table-semantic"; 8 + import { Textarea } from "baseui/textarea"; 9 + import { StatefulTooltip } from "baseui/tooltip"; 10 + import { HeadingMedium } from "baseui/typography"; 11 + import copy from "copy-to-clipboard"; 12 + import { useEffect, useState } from "react"; 13 + import { Controller, useForm } from "react-hook-form"; 14 + import z from "zod"; 15 + import useApikey from "../../hooks/useApikey"; 16 + import Main from "../../layouts/Main"; 17 + import { ApiKey } from "../../types/apikey"; 18 + import { Code, Header } from "./styles"; 19 + 20 + const schema = z.object({ 21 + name: z.string().min(1, "Name is required"), 22 + description: z.string().optional(), 23 + }); 24 + 25 + function ApiKeys() { 26 + const [isOpen, setIsOpen] = useState(false); 27 + const [apikeys, setApikeys] = useState<ApiKey[]>([]); 28 + const [enabled, setEnabled] = useState<{ 29 + [key: string]: boolean; 30 + }>({}); 31 + const { 32 + control, 33 + formState: { errors }, 34 + clearErrors, 35 + reset, 36 + getValues, 37 + } = useForm({ 38 + resolver: zodResolver(schema), 39 + mode: "onBlur", 40 + }); 41 + const { createApiKey, getApiKeys, updateApiKey, deleteApiKey } = useApikey(); 42 + 43 + const fetchApiKeys = async () => { 44 + try { 45 + const response = await getApiKeys(); 46 + setApikeys(response.data); 47 + } catch (error) { 48 + console.error("Error fetching API keys:", error); 49 + } 50 + }; 51 + 52 + useEffect(() => { 53 + fetchApiKeys(); 54 + // eslint-disable-next-line react-hooks/exhaustive-deps 55 + }, []); 56 + 57 + const onCreate = async () => { 58 + if (errors.name) { 59 + return; 60 + } 61 + 62 + const values = getValues(); 63 + 64 + await createApiKey(values.name, values.description); 65 + 66 + setIsOpen(false); 67 + clearErrors(); 68 + reset(); 69 + 70 + await fetchApiKeys(); 71 + }; 72 + 73 + const onDisable = async (id: string) => { 74 + setEnabled((prev) => ({ 75 + ...prev, 76 + [id]: false, 77 + })); 78 + await updateApiKey(id, false); 79 + await fetchApiKeys(); 80 + }; 81 + 82 + const onEnable = async (id: string) => { 83 + setEnabled((prev) => ({ 84 + ...prev, 85 + [id]: true, 86 + })); 87 + await updateApiKey(id, true); 88 + await fetchApiKeys(); 89 + }; 90 + 91 + const onDelete = async (id: string) => { 92 + await deleteApiKey(id); 93 + await fetchApiKeys(); 94 + }; 95 + 96 + return ( 97 + <Main withRightPane={false}> 98 + <div 99 + style={{ 100 + marginTop: 70, 101 + marginBottom: 150, 102 + }} 103 + > 104 + <Header> 105 + <HeadingMedium marginTop={"0px"} marginBottom={"20px"}> 106 + API Applications 107 + </HeadingMedium> 108 + <Button 109 + startEnhancer={() => ( 110 + <Plus 111 + size={24} 112 + style={{ 113 + color: "white", 114 + }} 115 + /> 116 + )} 117 + onClick={() => { 118 + setIsOpen(true); 119 + }} 120 + overrides={{ 121 + BaseButton: { 122 + style: { 123 + backgroundColor: "rgb(255, 40, 118)", 124 + ":hover": { 125 + backgroundColor: "rgb(255, 40, 118)", 126 + opacity: 0.8, 127 + }, 128 + ":focus": { 129 + backgroundColor: "rgb(255, 40, 118)", 130 + opacity: 0.8, 131 + }, 132 + height: "50px", 133 + }, 134 + }, 135 + }} 136 + > 137 + New API Key 138 + </Button> 139 + </Header> 140 + <TableBuilder data={apikeys} emptyMessage="No API keys found"> 141 + <TableBuilderColumn header="Name"> 142 + {(row: ApiKey) => ( 143 + <div 144 + style={{ 145 + display: "flex", 146 + flexDirection: "row", 147 + alignItems: "center", 148 + }} 149 + > 150 + {row.name} 151 + </div> 152 + )} 153 + </TableBuilderColumn> 154 + <TableBuilderColumn 155 + header="Keys" 156 + overrides={{ 157 + TableBodyCell: { 158 + style: { 159 + width: "300px", 160 + }, 161 + }, 162 + }} 163 + > 164 + {(row: ApiKey) => ( 165 + <div> 166 + <div>API Key:</div> 167 + <Code>{row.apiKey}</Code> 168 + <StatefulTooltip content="Copy API Key"> 169 + <Copy 170 + onClick={() => copy(row.apiKey)} 171 + size={18} 172 + color="#000000a0" 173 + style={{ marginLeft: 5, cursor: "pointer" }} 174 + /> 175 + </StatefulTooltip> 176 + <div style={{ marginTop: "5px" }}>Shared Secret:</div> 177 + <Code>{row.sharedSecret}</Code> 178 + <StatefulTooltip content="Copy Shared Secret"> 179 + <Copy 180 + onClick={() => copy(row.sharedSecret)} 181 + size={18} 182 + color="#000000a0" 183 + style={{ marginLeft: 5, cursor: "pointer" }} 184 + /> 185 + </StatefulTooltip> 186 + </div> 187 + )} 188 + </TableBuilderColumn> 189 + <TableBuilderColumn header="Description"> 190 + {(row: ApiKey) => <div>{row.description}</div>} 191 + </TableBuilderColumn> 192 + <TableBuilderColumn 193 + header="Action" 194 + overrides={{ 195 + TableBodyCell: { 196 + style: { 197 + width: "150px", 198 + }, 199 + }, 200 + }} 201 + > 202 + {(row: ApiKey) => ( 203 + <div> 204 + {(enabled[row.id] || row.enabled) && ( 205 + <Button kind="secondary" onClick={() => onDisable(row.id)}> 206 + Disable 207 + </Button> 208 + )} 209 + {!enabled[row.id] && !row.enabled && ( 210 + <Button kind="secondary" onClick={() => onEnable(row.id)}> 211 + Enable 212 + </Button> 213 + )} 214 + <Trash 215 + onClick={() => onDelete(row.id)} 216 + size={20} 217 + color="#000000a0" 218 + style={{ 219 + marginLeft: 10, 220 + marginTop: -3, 221 + cursor: "pointer", 222 + }} 223 + /> 224 + </div> 225 + )} 226 + </TableBuilderColumn> 227 + </TableBuilder> 228 + </div> 229 + <Modal 230 + isOpen={isOpen} 231 + onClose={() => setIsOpen(false)} 232 + overrides={{ 233 + Root: { 234 + style: { 235 + zIndex: 1, 236 + }, 237 + }, 238 + }} 239 + > 240 + <ModalHeader>Create a new API key</ModalHeader> 241 + <ModalBody> 242 + <Controller 243 + name="name" 244 + control={control} 245 + render={({ field }) => ( 246 + <Input 247 + {...field} 248 + placeholder="Name" 249 + clearOnEscape 250 + error={!!errors.name} 251 + /> 252 + )} 253 + /> 254 + <Controller 255 + name="description" 256 + control={control} 257 + render={({ field }) => ( 258 + <Textarea 259 + {...field} 260 + placeholder="Description (Optional)" 261 + clearOnEscape 262 + error={!!errors.description} 263 + overrides={{ 264 + Root: { 265 + style: { 266 + marginTop: "20px", 267 + }, 268 + }, 269 + }} 270 + /> 271 + )} 272 + /> 273 + </ModalBody> 274 + <ModalFooter> 275 + <Button 276 + kind="tertiary" 277 + onClick={() => setIsOpen(false)} 278 + overrides={{ 279 + BaseButton: { 280 + style: { 281 + marginRight: "10px", 282 + }, 283 + }, 284 + }} 285 + > 286 + Cancel 287 + </Button> 288 + <Button onClick={onCreate}>Create</Button> 289 + </ModalFooter> 290 + </Modal> 291 + </Main> 292 + ); 293 + } 294 + 295 + export default ApiKeys;
+3
rockskyweb/src/pages/apikeys/index.tsx
··· 1 + import ApiKeys from "./ApiKeys"; 2 + 3 + export default ApiKeys;
+16
rockskyweb/src/pages/apikeys/styles.tsx
··· 1 + import styled from "@emotion/styled"; 2 + 3 + export const Header = styled.div` 4 + display: flex; 5 + flex-direction: row; 6 + justify-content: space-between; 7 + margin-bottom: 30px; 8 + `; 9 + 10 + export const Code = styled.div` 11 + background-color: #000; 12 + color: #fff; 13 + padding: 5px; 14 + display: inline-block; 15 + border-radius: 5px; 16 + `;
+9
rockskyweb/src/types/apikey.ts
··· 1 + export type ApiKey = { 2 + id: string; 3 + name: string; 4 + description?: string; 5 + apiKey: string; 6 + sharedSecret: string; 7 + enabled: boolean; 8 + createdAt: string; 9 + };