forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
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}