A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at setup-tracing 933 lines 31 kB view raw
1use anyhow::Error; 2use async_nats::{connect, Client}; 3use duckdb::{params, Connection}; 4use owo_colors::OwoColorize; 5use std::{ 6 env, 7 sync::{Arc, Mutex}, 8 thread, 9}; 10use tokio_stream::StreamExt; 11use types::{LikePayload, NewTrackPayload, ScrobblePayload, UnlikePayload, UserPayload}; 12 13pub mod types; 14 15pub async fn subscribe(conn: Arc<Mutex<Connection>>) -> Result<(), Error> { 16 let addr = env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string()); 17 let conn = conn.clone(); 18 let nc = connect(&addr).await?; 19 tracing::info!(server = %addr.bright_green(), "Connected to NATS"); 20 21 let nc = Arc::new(Mutex::new(nc)); 22 on_scrobble(nc.clone(), conn.clone()); 23 on_new_track(nc.clone(), conn.clone()); 24 on_like(nc.clone(), conn.clone()); 25 on_unlike(nc.clone(), conn.clone()); 26 on_new_user(nc.clone(), conn.clone()); 27 28 Ok(()) 29} 30 31pub fn on_scrobble(nc: Arc<Mutex<Client>>, conn: Arc<Mutex<Connection>>) { 32 thread::spawn(move || { 33 let rt = tokio::runtime::Runtime::new().unwrap(); 34 let conn = conn.clone(); 35 let nc = nc.clone(); 36 rt.block_on(async { 37 let nc = nc.lock().unwrap(); 38 let mut sub = nc.subscribe("rocksky.scrobble".to_string()).await?; 39 drop(nc); 40 41 while let Some(msg) = sub.next().await { 42 let data = String::from_utf8(msg.payload.to_vec()).unwrap(); 43 match serde_json::from_str::<ScrobblePayload>(&data) { 44 Ok(payload) => match save_scrobble(conn.clone(), payload.clone()).await { 45 Ok(_) => tracing::info!( 46 uri = %payload.scrobble.uri.cyan(), 47 "Scrobble saved successfully", 48 ), 49 Err(e) => tracing::error!("Error saving scrobble: {}", e), 50 }, 51 Err(e) => { 52 tracing::error!("Error parsing payload: {}", e); 53 tracing::debug!("{}", data); 54 } 55 } 56 } 57 58 Ok::<(), Error>(()) 59 })?; 60 61 Ok::<(), Error>(()) 62 }); 63} 64 65pub fn on_new_track(nc: Arc<Mutex<Client>>, conn: Arc<Mutex<Connection>>) { 66 thread::spawn(move || { 67 let rt = tokio::runtime::Runtime::new().unwrap(); 68 let conn = conn.clone(); 69 let nc = nc.clone(); 70 rt.block_on(async { 71 let nc = nc.lock().unwrap(); 72 let mut sub = nc.subscribe("rocksky.track".to_string()).await?; 73 drop(nc); 74 75 while let Some(msg) = sub.next().await { 76 let data = String::from_utf8(msg.payload.to_vec()).unwrap(); 77 match serde_json::from_str::<NewTrackPayload>(&data) { 78 Ok(payload) => match save_track(conn.clone(), payload.clone()).await { 79 Ok(_) => { 80 tracing::info!( 81 title = %payload.track.title.cyan(), 82 "Track saved successfully", 83 ) 84 } 85 Err(e) => tracing::error!("Error saving track: {}", e), 86 }, 87 Err(e) => { 88 tracing::error!("Error parsing payload: {}", e); 89 tracing::debug!("{}", data); 90 } 91 } 92 } 93 94 Ok::<(), Error>(()) 95 })?; 96 97 Ok::<(), Error>(()) 98 }); 99} 100 101pub fn on_like(nc: Arc<Mutex<Client>>, conn: Arc<Mutex<Connection>>) { 102 thread::spawn(move || { 103 let rt = tokio::runtime::Runtime::new().unwrap(); 104 let conn = conn.clone(); 105 let nc = nc.clone(); 106 rt.block_on(async { 107 let nc = nc.lock().unwrap(); 108 let mut sub = nc.subscribe("rocksky.like".to_string()).await?; 109 drop(nc); 110 111 while let Some(msg) = sub.next().await { 112 let data = String::from_utf8(msg.payload.to_vec()).unwrap(); 113 match serde_json::from_str::<LikePayload>(&data) { 114 Ok(payload) => match like(conn.clone(), payload.clone()).await { 115 Ok(_) => tracing::info!( 116 track_id = %payload.track_id.xata_id.cyan(), 117 "Like saved successfully", 118 ), 119 Err(e) => tracing::error!("Error saving like: {}", e), 120 }, 121 Err(e) => { 122 tracing::error!("Error parsing payload: {}", e); 123 tracing::debug!("{}", data); 124 } 125 } 126 } 127 128 Ok::<(), Error>(()) 129 })?; 130 131 Ok::<(), Error>(()) 132 }); 133} 134 135pub fn on_unlike(nc: Arc<Mutex<Client>>, conn: Arc<Mutex<Connection>>) { 136 thread::spawn(move || { 137 let rt = tokio::runtime::Runtime::new().unwrap(); 138 let conn = conn.clone(); 139 let nc = nc.clone(); 140 rt.block_on(async { 141 let nc = nc.lock().unwrap(); 142 let mut sub = nc.subscribe("rocksky.unlike".to_string()).await?; 143 drop(nc); 144 145 while let Some(msg) = sub.next().await { 146 let data = String::from_utf8(msg.payload.to_vec()).unwrap(); 147 match serde_json::from_str::<UnlikePayload>(&data) { 148 Ok(payload) => match unlike(conn.clone(), payload.clone()).await { 149 Ok(_) => tracing::info!( 150 track_id = %payload.track_id.xata_id.cyan(), 151 "Unlike saved successfully", 152 ), 153 Err(e) => tracing::error!("Error saving unlike: {}", e), 154 }, 155 Err(e) => { 156 tracing::error!("Error parsing payload: {}", e); 157 tracing::debug!("{}", data); 158 } 159 } 160 } 161 162 Ok::<(), Error>(()) 163 })?; 164 165 Ok::<(), Error>(()) 166 }); 167} 168 169pub fn on_new_user(nc: Arc<Mutex<Client>>, conn: Arc<Mutex<Connection>>) { 170 thread::spawn(move || { 171 let rt = tokio::runtime::Runtime::new().unwrap(); 172 let conn = conn.clone(); 173 let nc = nc.clone(); 174 rt.block_on(async { 175 let nc = nc.lock().unwrap(); 176 let mut sub = nc.subscribe("rocksky.user".to_string()).await?; 177 drop(nc); 178 179 while let Some(msg) = sub.next().await { 180 let data = String::from_utf8(msg.payload.to_vec()).unwrap(); 181 match serde_json::from_str::<UserPayload>(&data) { 182 Ok(payload) => match save_user(conn.clone(), payload.clone()).await { 183 Ok(_) => tracing::info!( 184 handle = %payload.handle.cyan(), 185 "User saved successfully", 186 ), 187 Err(e) => tracing::error!("Error saving user: {}", e), 188 }, 189 Err(e) => { 190 tracing::error!("Error parsing payload: {}", e); 191 tracing::debug!("{}", data); 192 } 193 } 194 } 195 196 Ok::<(), Error>(()) 197 })?; 198 199 Ok::<(), Error>(()) 200 }); 201} 202 203pub async fn save_scrobble( 204 conn: Arc<Mutex<Connection>>, 205 payload: ScrobblePayload, 206) -> Result<(), Error> { 207 let conn = conn.lock().unwrap(); 208 209 match conn.execute( 210 "INSERT INTO artists ( 211 id, 212 name, 213 biography, 214 born, 215 born_in, 216 died, 217 picture, 218 sha256, 219 spotify_link, 220 tidal_link, 221 youtube_link, 222 apple_music_link, 223 uri 224 ) VALUES ( 225 ?, 226 ?, 227 ?, 228 ?, 229 ?, 230 ?, 231 ?, 232 ?, 233 ?, 234 ?, 235 ?, 236 ?, 237 ? 238 )", 239 params![ 240 payload.scrobble.artist_id.xata_id, 241 payload.scrobble.artist_id.name, 242 payload.scrobble.artist_id.biography, 243 payload.scrobble.artist_id.born, 244 payload.scrobble.artist_id.born_in, 245 payload.scrobble.artist_id.died, 246 payload.scrobble.artist_id.picture, 247 payload.scrobble.artist_id.sha256, 248 payload.scrobble.artist_id.spotify_link, 249 payload.scrobble.artist_id.tidal_link, 250 payload.scrobble.artist_id.youtube_link, 251 payload.scrobble.artist_id.apple_music_link, 252 payload.scrobble.artist_id.uri, 253 ], 254 ) { 255 Ok(_) => (), 256 Err(e) => { 257 if !e.to_string().contains("violates primary key constraint") { 258 tracing::error!("[artists] error: {}", e); 259 return Err(e.into()); 260 } 261 } 262 } 263 264 match conn.execute( 265 "INSERT INTO albums ( 266 id, 267 title, 268 artist, 269 release_date, 270 album_art, 271 year, 272 spotify_link, 273 tidal_link, 274 youtube_link, 275 apple_music_link, 276 sha256, 277 uri, 278 artist_uri 279 ) VALUES ( 280 ?, 281 ?, 282 ?, 283 ?, 284 ?, 285 ?, 286 ?, 287 ?, 288 ?, 289 ?, 290 ?, 291 ?, 292 ? 293 )", 294 params![ 295 payload.scrobble.album_id.xata_id, 296 payload.scrobble.album_id.title, 297 payload.scrobble.album_id.artist, 298 payload.scrobble.album_id.release_date, 299 payload.scrobble.album_id.album_art, 300 payload.scrobble.album_id.year, 301 payload.scrobble.album_id.spotify_link, 302 payload.scrobble.album_id.tidal_link, 303 payload.scrobble.album_id.youtube_link, 304 payload.scrobble.album_id.apple_music_link, 305 payload.scrobble.album_id.sha256, 306 payload.scrobble.album_id.uri, 307 payload.scrobble.album_id.artist_uri, 308 ], 309 ) { 310 Ok(_) => (), 311 Err(e) => { 312 if !e.to_string().contains("violates primary key constraint") { 313 tracing::error!("[albums] error: {}", e); 314 return Err(e.into()); 315 } 316 } 317 } 318 319 match conn.execute( 320 "INSERT INTO tracks ( 321 id, 322 title, 323 artist, 324 album_artist, 325 album_art, 326 album, 327 track_number, 328 duration, 329 mb_id, 330 youtube_link, 331 spotify_link, 332 tidal_link, 333 apple_music_link, 334 sha256, 335 lyrics, 336 composer, 337 genre, 338 disc_number, 339 copyright_message, 340 label, 341 uri, 342 artist_uri, 343 album_uri, 344 created_at 345 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 346 params![ 347 payload.scrobble.track_id.xata_id, 348 payload.scrobble.track_id.title, 349 payload.scrobble.track_id.artist, 350 payload.scrobble.track_id.album_artist, 351 payload.scrobble.track_id.album_art, 352 payload.scrobble.track_id.album, 353 payload.scrobble.track_id.track_number, 354 payload.scrobble.track_id.duration, 355 payload.scrobble.track_id.mb_id, 356 payload.scrobble.track_id.youtube_link, 357 payload.scrobble.track_id.spotify_link, 358 payload.scrobble.track_id.tidal_link, 359 payload.scrobble.track_id.apple_music_link, 360 payload.scrobble.track_id.sha256, 361 payload.scrobble.track_id.lyrics, 362 payload.scrobble.track_id.composer, 363 payload.scrobble.track_id.genre, 364 payload.scrobble.track_id.disc_number, 365 payload.scrobble.track_id.copyright_message, 366 payload.scrobble.track_id.label, 367 payload.scrobble.track_id.uri, 368 payload.scrobble.track_id.artist_uri, 369 payload.scrobble.track_id.album_uri, 370 payload.scrobble.track_id.xata_createdat, 371 ], 372 ) { 373 Ok(_) => (), 374 Err(e) => { 375 if !e.to_string().contains("violates primary key constraint") { 376 tracing::error!("[tracks] error: {}", e); 377 return Err(e.into()); 378 } 379 } 380 } 381 382 match conn.execute( 383 "INSERT INTO album_tracks ( 384 id, 385 album_id, 386 track_id 387 ) VALUES (?, 388 ?, 389 ?)", 390 params![ 391 payload.album_track.xata_id, 392 payload.album_track.album_id.xata_id, 393 payload.album_track.track_id.xata_id, 394 ], 395 ) { 396 Ok(_) => (), 397 Err(e) => { 398 if !e.to_string().contains("violates primary key constraint") { 399 tracing::error!("[album_tracks] error: {}", e); 400 return Err(e.into()); 401 } 402 } 403 } 404 405 match conn.execute( 406 "INSERT INTO artist_tracks (id, artist_id, track_id, created_at) VALUES (?, ?, ?, ?)", 407 params![ 408 payload.artist_track.xata_id, 409 payload.artist_track.artist_id.xata_id, 410 payload.artist_track.track_id.xata_id, 411 payload.artist_track.xata_createdat, 412 ], 413 ) { 414 Ok(_) => (), 415 Err(e) => { 416 if !e.to_string().contains("violates primary key constraint") { 417 tracing::error!("[artist_tracks] error: {}", e); 418 return Err(e.into()); 419 } 420 } 421 } 422 423 match conn.execute( 424 "INSERT INTO artist_albums (id, artist_id, album_id, created_at) VALUES (?, ?, ?, ?)", 425 params![ 426 payload.artist_album.xata_id, 427 payload.artist_album.artist_id.xata_id, 428 payload.artist_album.album_id.xata_id, 429 payload.artist_album.xata_createdat, 430 ], 431 ) { 432 Ok(_) => (), 433 Err(e) => { 434 if !e.to_string().contains("violates primary key constraint") { 435 tracing::error!("[artist_albums] error: {}", e); 436 return Err(e.into()); 437 } 438 } 439 } 440 441 match conn.execute( 442 "INSERT INTO user_albums (id, user_id, album_id, created_at) VALUES (?, ?, ?, ?)", 443 params![ 444 payload.user_album.xata_id, 445 payload.user_album.user_id.xata_id, 446 payload.user_album.album_id.xata_id, 447 payload.user_album.xata_createdat, 448 ], 449 ) { 450 Ok(_) => (), 451 Err(e) => { 452 if !e.to_string().contains("violates primary key constraint") { 453 tracing::error!("[user_albums] error: {}", e); 454 return Err(e.into()); 455 } 456 } 457 } 458 459 match conn.execute( 460 "INSERT INTO user_artists (id, user_id, artist_id, created_at) VALUES (?, ?, ?, ?)", 461 params![ 462 payload.user_artist.xata_id, 463 payload.user_artist.user_id.xata_id, 464 payload.user_artist.artist_id.xata_id, 465 payload.user_artist.xata_createdat, 466 ], 467 ) { 468 Ok(_) => (), 469 Err(e) => { 470 if !e.to_string().contains("violates primary key constraint") { 471 tracing::error!("[user_artists] error: {}", e); 472 return Err(e.into()); 473 } 474 } 475 } 476 477 match conn.execute( 478 "INSERT INTO user_tracks (id, user_id, track_id, created_at) VALUES (?, ?, ?, ?)", 479 params![ 480 payload.user_track.xata_id, 481 payload.user_track.user_id.xata_id, 482 payload.user_track.track_id.xata_id, 483 payload.user_track.xata_createdat, 484 ], 485 ) { 486 Ok(_) => (), 487 Err(e) => { 488 if !e.to_string().contains("violates primary key constraint") { 489 tracing::error!("[user_tracks] error: {}", e); 490 return Err(e.into()); 491 } 492 } 493 } 494 495 match conn.execute( 496 "INSERT INTO scrobbles ( 497 id, 498 user_id, 499 track_id, 500 album_id, 501 artist_id, 502 uri, 503 created_at 504 ) VALUES ( 505 ?, 506 ?, 507 ?, 508 ?, 509 ?, 510 ?, 511 ? 512 )", 513 params![ 514 payload.scrobble.xata_id, 515 payload.scrobble.user_id.xata_id, 516 payload.scrobble.track_id.xata_id, 517 payload.scrobble.album_id.xata_id, 518 payload.scrobble.artist_id.xata_id, 519 payload.scrobble.uri, 520 payload.scrobble.timestamp, 521 ], 522 ) { 523 Ok(_) => (), 524 Err(e) => { 525 if !e.to_string().contains("violates primary key constraint") { 526 tracing::error!("[scrobbles] error: {}", e); 527 return Err(e.into()); 528 } 529 } 530 } 531 532 Ok(()) 533} 534 535pub async fn save_track( 536 conn: Arc<Mutex<Connection>>, 537 payload: NewTrackPayload, 538) -> Result<(), Error> { 539 let conn = conn.lock().unwrap(); 540 541 match conn.execute( 542 "INSERT INTO tracks ( 543 id, 544 title, 545 artist, 546 album_artist, 547 album_art, 548 album, 549 track_number, 550 duration, 551 mb_id, 552 youtube_link, 553 spotify_link, 554 tidal_link, 555 apple_music_link, 556 sha256, 557 lyrics, 558 composer, 559 genre, 560 disc_number, 561 copyright_message, 562 label, 563 uri, 564 artist_uri, 565 album_uri, 566 created_at 567 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 568 params![ 569 payload.track.xata_id, 570 payload.track.title, 571 payload.track.artist, 572 payload.track.album_artist, 573 payload.track.album_art, 574 payload.track.album, 575 payload.track.track_number, 576 payload.track.duration, 577 payload.track.mb_id, 578 payload.track.youtube_link, 579 payload.track.spotify_link, 580 payload.track.tidal_link, 581 payload.track.apple_music_link, 582 payload.track.sha256, 583 payload.track.lyrics, 584 payload.track.composer, 585 payload.track.genre, 586 payload.track.disc_number, 587 payload.track.copyright_message, 588 payload.track.label, 589 payload.track.uri, 590 payload.track.artist_uri, 591 payload.track.album_uri, 592 payload.track.xata_createdat, 593 ], 594 ) { 595 Ok(_) => (), 596 Err(e) => { 597 if !e.to_string().contains("violates primary key constraint") { 598 tracing::error!("[tracks] error: {}", e); 599 return Err(e.into()); 600 } 601 } 602 } 603 604 match conn.execute( 605 "INSERT INTO album_tracks ( 606 id, 607 album_id, 608 track_id 609 ) VALUES (?, 610 ?, 611 ?)", 612 params![ 613 payload.album_track.xata_id, 614 payload.album_track.album_id.xata_id, 615 payload.album_track.track_id.xata_id, 616 ], 617 ) { 618 Ok(_) => (), 619 Err(e) => { 620 if !e.to_string().contains("violates primary key constraint") { 621 tracing::error!("[album_tracks] error: {}", e); 622 return Err(e.into()); 623 } 624 } 625 } 626 627 match conn.execute( 628 "INSERT INTO artist_tracks (id, artist_id, track_id, created_at) VALUES (?, ?, ?, ?)", 629 params![ 630 payload.artist_track.xata_id, 631 payload.artist_track.artist_id.xata_id, 632 payload.artist_track.track_id.xata_id, 633 payload.artist_track.xata_createdat, 634 ], 635 ) { 636 Ok(_) => (), 637 Err(e) => { 638 if !e.to_string().contains("violates primary key constraint") { 639 tracing::error!("[artist_tracks] error: {}", e); 640 return Err(e.into()); 641 } 642 } 643 } 644 645 match conn.execute( 646 "INSERT INTO artist_albums (id, artist_id, album_id, created_at) VALUES (?, ?, ?, ?)", 647 params![ 648 payload.artist_album.xata_id, 649 payload.artist_album.artist_id.xata_id, 650 payload.artist_album.album_id.xata_id, 651 payload.artist_album.xata_createdat, 652 ], 653 ) { 654 Ok(_) => (), 655 Err(e) => { 656 if !e.to_string().contains("violates primary key constraint") { 657 tracing::error!("[artist_albums] error: {}", e); 658 return Err(e.into()); 659 } 660 } 661 } 662 Ok(()) 663} 664 665pub async fn like(conn: Arc<Mutex<Connection>>, payload: LikePayload) -> Result<(), Error> { 666 let conn = conn.lock().unwrap(); 667 match conn.execute( 668 "INSERT INTO loved_tracks ( 669 id, 670 user_id, 671 track_id, 672 created_at 673 ) VALUES ( 674 ?, 675 ?, 676 ?, 677 ? 678 )", 679 params![ 680 payload.xata_id, 681 payload.user_id.xata_id, 682 payload.track_id.xata_id, 683 payload.xata_createdat, 684 ], 685 ) { 686 Ok(_) => (), 687 Err(e) => { 688 if !e.to_string().contains("violates primary key constraint") { 689 tracing::error!("[likes] error: {}", e); 690 return Err(e.into()); 691 } 692 } 693 } 694 Ok(()) 695} 696 697pub async fn unlike(conn: Arc<Mutex<Connection>>, payload: UnlikePayload) -> Result<(), Error> { 698 let conn = conn.lock().unwrap(); 699 match conn.execute( 700 "DELETE FROM loved_tracks WHERE user_id = ? AND track_id = ?", 701 params![payload.user_id.xata_id, payload.track_id.xata_id,], 702 ) { 703 Ok(_) => (), 704 Err(e) => { 705 tracing::error!("[unlikes] error: {}", e); 706 return Err(e.into()); 707 } 708 } 709 Ok(()) 710} 711 712pub async fn save_user(conn: Arc<Mutex<Connection>>, payload: UserPayload) -> Result<(), Error> { 713 let conn = conn.lock().unwrap(); 714 715 match conn.execute( 716 "INSERT INTO users ( 717 id, 718 avatar, 719 did, 720 display_name, 721 handle 722 ) VALUES ( 723 ?, 724 ?, 725 ?, 726 ?, 727 ? 728 ) 729 ON CONFLICT (id) DO UPDATE SET 730 avatar = EXCLUDED.avatar, 731 did = EXCLUDED.did, 732 display_name = EXCLUDED.display_name, 733 handle = EXCLUDED.handle", 734 params![ 735 payload.xata_id, 736 payload.avatar, 737 payload.did, 738 payload.display_name, 739 payload.handle, 740 ], 741 ) { 742 Ok(_) => (), 743 Err(e) => { 744 if !e.to_string().contains("violates primary key constraint") { 745 tracing::error!("[users] error: {}", e); 746 return Err(e.into()); 747 } 748 } 749 } 750 Ok(()) 751} 752 753#[cfg(test)] 754mod tests { 755 756 use super::types; 757 758 #[test] 759 fn test_parse_scrobble() { 760 let data = r#" 761 { 762 "scrobble": { 763 "album_id": { 764 "album_art": "https://cdn.rocksky.app/covers/9e004bc175df6c338cab2a9e465b736f.jpg", 765 "artist": "Kid Ink", 766 "artist_uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.artist/3lhlly4tvws2k", 767 "release_date": "2012-06-26T00:00:00.000Z", 768 "sha256": "8d3f54501cf22aeb5d7ecb2a21c43b8a0b21839df3c61007ec781b278ec2806f", 769 "title": "Up & Away", 770 "uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.album/3lhlly5k7sk2k", 771 "xata_createdat": "2025-02-05T22:54:59.422Z", 772 "xata_id": "rec_cuhuogpo74fi003af7og", 773 "xata_updatedat": "2025-03-03T07:20:51.237Z", 774 "xata_version": 29, 775 "year": 2012, 776 "apple_music_link": null, 777 "spotify_link": null, 778 "tidal_link": null, 779 "youtube_link": null 780 }, 781 "artist_id": { 782 "name": "Kid Ink", 783 "picture": "https://i.scdn.co/image/ab6761610000e5ebf4904a817005f3b96f4e6e53", 784 "sha256": "7e9e30fecceedb10bf69e0c81dd036aeb5cf83befb0c3aeedf84684fe1ab1860", 785 "uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.artist/3lhlly4tvws2k", 786 "xata_createdat": "2025-02-05T22:40:50.310Z", 787 "xata_id": "rec_cuhuhsho74fi003af740", 788 "xata_updatedat": "2025-03-03T07:20:50.648Z", 789 "xata_version": 82, 790 "apple_music_link": null, 791 "biography": null, 792 "born": null, 793 "born_in": null, 794 "died": null, 795 "spotify_link": null, 796 "tidal_link": null, 797 "youtube_link": null 798 }, 799 "track_id": { 800 "album": "Up & Away", 801 "album_art": "https://cdn.rocksky.app/covers/9e004bc175df6c338cab2a9e465b736f.jpg", 802 "album_artist": "Kid Ink", 803 "album_uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.album/3lhlly5k7sk2k", 804 "artist": "Kid Ink", 805 "artist_uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.artist/3lhlly4tvws2k", 806 "composer": "The Arsenals", 807 "copyright_message": "2012 Tha Alumni", 808 "disc_number": 1, 809 "duration": 251922, 810 "lyrics": "[00:11.91] I know, they ain't know what I'm on\n[00:26.97] Sorry excuse me, how I'm feelin' right now\n[00:30.12] Soon they gon' understand that\n[00:32.80] Try to do it like me you can tell 'em\n[00:35.63] I'm a beast, I'm a dog, they let me off the leash\n[00:39.12] Now I'm comin' for 'em all\n[00:40.87] Man I need another drink, it's the last call\n[00:43.79] Just gimme a minute lemme show 'em how I ball\n[00:46.60] Then we'll roll out, let's roll out\n[00:50.31] Let's roll out, we could roll out\n[00:59.92] Live, reportin' from the cockpit\n[01:02.62] Red eyes but I'm tryna get my mind clear\n[01:05.60] Celebratin' like we just won a contest\n[01:08.80] No contest, motherfuckers couldn't digest\n[01:11.66] What I'm on, man of my home\n[01:14.46] Bands on deck, you ain't gotta blow my horn\n[01:17.54] Paint a perfect picture like frida kahlo\n[01:20.41] Red or green pill don't trip just swallow that\n[01:23.77] And gon' have the time of your life\n[01:26.21] On me, no strings up, high as a kite\n[01:29.22] Watch the molly turn a straight girl right into a dyke\n[01:31.84] Soon you'll understand by the end of the night\n[01:35.04] Tell 'em\n[01:36.01] I know, they ain't know what I'm on\n[01:38.55] Sorry excuse me, how I'm feelin' right now\n[01:41.98] Soon they gon' understand that\n[01:44.63] Try to do it like me you can tell 'em\n[01:47.16] I'm a beast, I'm a dog, they let me off the leash\n[01:51.15] Now I'm comin' for 'em all\n[01:52.79] Man I need another drink, it's the last call\n[01:55.62] Just gimme a minute lemme show 'em how I ball\n[01:58.76] Then we'll roll out, let's roll out\n[02:02.97] Let's roll out, we could roll out\n[02:11.86] Just sayin', I need to get a point across\n[02:14.77] Somebody find these niggas cuz they fuckin' lost\n[02:17.70] Tryna be the boss, couldn't pay the cost\n[02:20.77] Let my chain speak for me we ain't gotta talk\n[02:23.73] I go, til, the bottle's, hollow\n[02:27.50] Smokin' on diablo, smellin' like patron and\n[02:30.68] Marc jacob's cologne, up & away new generation\n[02:34.65] Apollo shit, so ready to roll, and rockout\n[02:38.72] These lames can't ball like the nba lockout\n[02:41.11] Hit 'em in the head, might pull a knot out\n[02:44.65] Show these motherfuckers what they not 'bout\n[02:47.11] Tell 'em\n[02:48.17] ", 811 "sha256": "0565f7815bc60c7fd96341073dd6420ca0e21ee36279d381ac5acf361fd27183", 812 "title": "Roll Out", 813 "track_number": 8, 814 "uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.song/3lhlly2gob22k", 815 "xata_createdat": "2025-02-05T22:54:58.062Z", 816 "xata_id": "rec_cuhuogho74fi003af7o0", 817 "xata_updatedat": "2025-03-03T07:21:04.449Z", 818 "xata_version": 16, 819 "apple_music_link": "null", 820 "genre": "null", 821 "label": "null", 822 "mb_id": "null", 823 "spotify_link": null, 824 "tidal_link": null, 825 "youtube_link": null 826 }, 827 "uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.scrobble/3ljhfzlkhy225", 828 "user_id": { 829 "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:7vdlgi2bflelz7mmuxoqjfcr/bafkreiabxfnhhk72ik2vgze6yjnjzbxps37nutkzbmnoo67ffoasgyeqwm@jpeg", 830 "did": "did:plc:7vdlgi2bflelz7mmuxoqjfcr", 831 "display_name": "Tsiry Sandratraina 馃", 832 "handle": "tsiry-sandratraina.com", 833 "xata_createdat": "2025-02-03T04:39:54.139Z", 834 "xata_id": "rec_cug4h6ibhfbm7uq5dte0", 835 "xata_updatedat": "2025-02-03T04:39:54.139Z", 836 "xata_version": 0 837 }, 838 "xata_createdat": "2025-03-03T07:21:04.679Z", 839 "xata_id": "rec_cv2lgo4ddc7scqp7svv0", 840 "xata_updatedat": "2025-03-03T07:21:04.679Z", 841 "xata_version": 0 842 }, 843 "user_album": { 844 "album_id": { 845 "xata_id": "rec_cuhuogpo74fi003af7og" 846 }, 847 "scrobbles": 10, 848 "uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.album/3lhlly5k7sk2k", 849 "user_id": { 850 "xata_id": "rec_cug4h6ibhfbm7uq5dte0" 851 }, 852 "xata_createdat": "2025-02-09T05:27:35.019Z", 853 "xata_id": "rec_cuk3phssvaqtev3d9l60", 854 "xata_updatedat": "2025-03-03T07:21:04.220Z", 855 "xata_version": 10 856 }, 857 "user_artist": { 858 "artist_id": { 859 "xata_id": "rec_cuhuhsho74fi003af740" 860 }, 861 "scrobbles": 21, 862 "uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.artist/3lhlly4tvws2k", 863 "user_id": { 864 "xata_id": "rec_cug4h6ibhfbm7uq5dte0" 865 }, 866 "xata_createdat": "2025-02-08T21:38:11.888Z", 867 "xata_id": "rec_cujstgpdl6q579droij0", 868 "xata_updatedat": "2025-03-03T07:21:03.643Z", 869 "xata_version": 21 870 }, 871 "user_track": { 872 "scrobbles": 6, 873 "track_id": { 874 "xata_id": "rec_cuhuogho74fi003af7o0" 875 }, 876 "uri": "at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.song/3lhlly2gob22k", 877 "user_id": { 878 "xata_id": "rec_cug4h6ibhfbm7uq5dte0" 879 }, 880 "xata_createdat": "2025-02-09T05:27:34.172Z", 881 "xata_id": "rec_cuk3phhdl6q579drp6f0", 882 "xata_updatedat": "2025-03-03T07:21:02.405Z", 883 "xata_version": 6 884 }, 885 "album_track": { 886 "album_id": { 887 "xata_id": "rec_cuhuogpo74fi003af7og" 888 }, 889 "track_id": { 890 "xata_id": "rec_cuhuogho74fi003af7o0" 891 }, 892 "xata_createdat": "2025-02-05T22:54:59.922Z", 893 "xata_id": "rec_cuhuogpo74fi003af7p0", 894 "xata_updatedat": "2025-03-03T07:20:51.736Z", 895 "xata_version": 11 896 }, 897 "artist_track": { 898 "artist_id": { 899 "xata_id": "rec_cuhuhsho74fi003af740" 900 }, 901 "track_id": { 902 "xata_id": "rec_cuhuogho74fi003af7o0" 903 }, 904 "xata_createdat": "2025-02-05T22:55:00.706Z", 905 "xata_id": "rec_cuhuoh2e5drjqa1arhf0", 906 "xata_updatedat": "2025-03-03T07:20:52.218Z", 907 "xata_version": 11 908 }, 909 "artist_album": { 910 "album_id": { 911 "xata_id": "rec_cuhuogpo74fi003af7og" 912 }, 913 "artist_id": { 914 "xata_id": "rec_cuhuhsho74fi003af740" 915 }, 916 "xata_createdat": "2025-02-05T22:55:01.205Z", 917 "xata_id": "rec_cuhuohe7vkdf9dh0pkh0", 918 "xata_updatedat": "2025-03-03T07:20:53.007Z", 919 "xata_version": 29 920 } 921} 922 "#; 923 924 match serde_json::from_str::<types::ScrobblePayload>(data) { 925 Err(e) => { 926 tracing::error!("Error parsing payload: {}", e); 927 tracing::error!("{}", data); 928 } 929 Ok(_) => {} 930 } 931 assert!(true); 932 } 933}