forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1use serde::{Deserialize, Serialize};
2
3use crate::{musicbrainz, spotify, xata};
4
5#[derive(Deserialize, Debug, Clone)]
6pub struct Connector {
7 pub id: String,
8 pub js: String,
9 pub label: String,
10 pub matches: Vec<String>,
11}
12
13#[derive(Deserialize, Debug, Clone)]
14#[serde(rename_all = "camelCase")]
15pub struct IsRegrexEditedByUser {
16 pub album: bool,
17 pub album_artist: bool,
18 pub artist: bool,
19 pub track: bool,
20}
21
22#[derive(Deserialize, Debug, Clone)]
23#[serde(rename_all = "camelCase")]
24pub struct Flags {
25 pub finished_processing: bool,
26 pub has_blocked_tag: bool,
27 pub is_album_fetched: bool,
28 pub is_corrected_by_user: bool,
29 pub is_loved_in_service: Option<bool>,
30 pub is_marked_as_playing: bool,
31 pub is_regex_edited_by_user: IsRegrexEditedByUser,
32 pub is_replaying: bool,
33 pub is_scrobbled: bool,
34 pub is_skipped: bool,
35 pub is_valid: bool,
36}
37
38#[derive(Deserialize, Debug, Clone)]
39#[serde(rename_all = "camelCase")]
40pub struct Metadata {
41 pub album_url: Option<String>,
42 pub artist_url: Option<String>,
43 pub label: String,
44 pub start_timestamp: u64,
45 pub track_url: Option<String>,
46 pub user_play_count: Option<u32>,
47 pub userloved: Option<bool>,
48}
49
50#[derive(Deserialize, Debug, Clone)]
51#[serde(rename_all = "camelCase")]
52pub struct NoRegex {
53 pub album: Option<String>,
54 pub album_artist: Option<String>,
55 pub artist: String,
56 pub duration: Option<u32>,
57 pub track: String,
58}
59
60#[derive(Deserialize, Debug, Clone)]
61#[serde(rename_all = "camelCase")]
62pub struct Parsed {
63 pub album: Option<String>,
64 pub album_artist: Option<String>,
65 pub artist: String,
66 pub current_time: Option<u32>,
67 pub duration: Option<u32>,
68 pub is_playing: bool,
69 pub is_podcast: bool,
70 pub origin_url: Option<String>,
71 pub scrobbling_disallowed_reason: Option<String>,
72 pub track: String,
73 pub track_art: Option<String>,
74 #[serde(rename = "uniqueID")]
75 pub unique_id: Option<String>,
76}
77
78#[derive(Deserialize, Debug, Clone)]
79#[serde(rename_all = "camelCase")]
80pub struct Song {
81 pub connector: Connector,
82 pub controller_tab_id: u64,
83 pub flags: Flags,
84 pub metadata: Metadata,
85 pub no_regex: NoRegex,
86 pub parsed: Parsed,
87}
88
89#[derive(Deserialize, Debug, Clone)]
90#[serde(rename_all = "camelCase")]
91pub struct Processed {
92 pub album: String,
93 pub album_artist: Option<String>,
94 pub artist: String,
95 pub duration: u32,
96 pub track: String,
97}
98
99#[derive(Deserialize, Debug, Clone)]
100#[serde(rename_all = "camelCase")]
101pub struct Scrobble {
102 pub song: Song,
103}
104
105#[derive(Deserialize, Debug, Clone)]
106#[serde(rename_all = "camelCase")]
107pub struct ScrobbleRequest {
108 pub data: Scrobble,
109 pub event_name: String,
110 pub time: u64,
111}
112
113#[derive(Debug, Serialize, Deserialize, Default)]
114#[serde(rename_all = "camelCase")]
115pub struct Track {
116 pub title: String,
117 pub album: String,
118 pub artist: String,
119 pub album_artist: Option<String>,
120 pub duration: u32,
121 pub mbid: Option<String>,
122 pub track_number: u32,
123 pub release_date: Option<String>,
124 pub year: Option<u32>,
125 pub disc_number: u32,
126 pub album_art: Option<String>,
127 pub spotify_link: Option<String>,
128 pub label: Option<String>,
129 pub artist_picture: Option<String>,
130 pub timestamp: Option<u64>,
131}
132
133impl From<xata::track::Track> for Track {
134 fn from(track: xata::track::Track) -> Self {
135 Track {
136 title: track.title,
137 album: track.album,
138 artist: track.artist,
139 album_artist: Some(track.album_artist),
140 album_art: track.album_art,
141 spotify_link: track.spotify_link,
142 label: track.label,
143 artist_picture: None,
144 timestamp: None,
145 duration: track.duration as u32,
146 mbid: track.mb_id,
147 track_number: track.track_number as u32,
148 disc_number: track.disc_number as u32,
149 year: None,
150 release_date: None,
151 }
152 }
153}
154
155impl From<musicbrainz::recording::Recording> for Track {
156 fn from(recording: musicbrainz::recording::Recording) -> Self {
157 let artist_credit = recording
158 .artist_credit
159 .unwrap_or_default()
160 .first()
161 .map(|credit| credit.name.clone())
162 .unwrap_or_default();
163 let releases = recording.releases.unwrap_or_default();
164 let album_artist = releases
165 .first()
166 .and_then(|release| release.artist_credit.first())
167 .map(|credit| credit.name.clone());
168 let album = releases
169 .first()
170 .map(|release| release.title.clone())
171 .unwrap_or_default();
172 Track {
173 title: recording.title.clone(),
174 album,
175 artist: artist_credit,
176 album_artist,
177 duration: recording.length.unwrap_or_default(),
178 year: recording
179 .first_release_date
180 .as_ref()
181 .and_then(|date| date.split('-').next())
182 .and_then(|year| year.parse::<u32>().ok()),
183 release_date: recording.first_release_date.clone(),
184 track_number: releases
185 .first()
186 .and_then(|release| {
187 release
188 .media
189 .as_ref()
190 .and_then(|media| media.first())
191 .and_then(|media| {
192 media
193 .tracks
194 .as_ref()
195 .and_then(|tracks| tracks.first())
196 .map(|track| track.number.parse::<u32>().unwrap())
197 })
198 })
199 .unwrap_or_default(),
200 disc_number: releases
201 .first()
202 .and_then(|release| {
203 release
204 .media
205 .as_ref()
206 .and_then(|media| media.first())
207 .map(|media| media.position)
208 })
209 .unwrap_or_default(),
210 ..Default::default()
211 }
212 }
213}
214
215impl From<&spotify::types::Track> for Track {
216 fn from(track: &spotify::types::Track) -> Self {
217 Track {
218 title: track.name.clone(),
219 album: track.album.name.clone(),
220 artist: track
221 .artists
222 .iter()
223 .map(|artist| artist.name.clone())
224 .collect::<Vec<_>>()
225 .join(", "),
226 album_artist: track
227 .album
228 .artists
229 .first()
230 .map(|artist| artist.name.clone()),
231 duration: track.duration_ms as u32,
232 album_art: track.album.images.first().map(|image| image.url.clone()),
233 spotify_link: Some(track.external_urls.spotify.clone()),
234 artist_picture: track.album.artists.first().and_then(|artist| {
235 artist
236 .images
237 .as_ref()
238 .and_then(|images| images.first().map(|image| image.url.clone()))
239 }),
240 track_number: track.track_number,
241 disc_number: track.disc_number,
242 release_date: match track.album.release_date_precision.as_str() {
243 "day" => Some(track.album.release_date.clone()),
244 _ => None,
245 },
246 year: match track.album.release_date_precision.as_str() {
247 "day" => Some(
248 track
249 .album
250 .release_date
251 .split('-')
252 .next()
253 .unwrap()
254 .parse::<u32>()
255 .unwrap(),
256 ),
257 "year" => Some(track.album.release_date.parse::<u32>().unwrap()),
258 _ => None,
259 },
260 label: track.album.label.clone(),
261 ..Default::default()
262 }
263 }
264}
265
266impl From<spotify::types::Track> for Track {
267 fn from(track: spotify::types::Track) -> Self {
268 Track::from(&track)
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn test_tidal_scrobble_request() {
278 let json = r#"
279 {
280 "data": {
281 "song": {
282 "connector": {
283 "id": "tidal",
284 "js": "tidal.js",
285 "label": "Tidal",
286 "matches": [
287 "*://listen.tidalhifi.com/*",
288 "*://listen.tidal.com/*"
289 ]
290 },
291 "controllerTabId": 2105806618,
292 "flags": {
293 "finishedProcessing": true,
294 "hasBlockedTag": false,
295 "isAlbumFetched": false,
296 "isCorrectedByUser": false,
297 "isLovedInService": null,
298 "isMarkedAsPlaying": true,
299 "isRegexEditedByUser": {
300 "album": false,
301 "albumArtist": false,
302 "artist": false,
303 "track": false
304 },
305 "isReplaying": false,
306 "isScrobbled": false,
307 "isSkipped": false,
308 "isValid": true
309 },
310 "metadata": {
311 "albumUrl": "https://www.last.fm/music/Tee+Grizzley/Forever+My+Moment+%5BClean%5D+%5BClean%5D",
312 "artistUrl": "https://www.last.fm/music/Tee+Grizzley",
313 "label": "Tidal",
314 "startTimestamp": 1747766980,
315 "trackUrl": "https://www.last.fm/music/Tee+Grizzley/_/Forever+My+Moment",
316 "userPlayCount": 0,
317 "userloved": false
318 },
319 "noRegex": {
320 "album": "FOREVER MY MOMENT",
321 "albumArtist": null,
322 "artist": "Tee Grizzley",
323 "duration": null,
324 "track": "Forever My Moment"
325 },
326 "parsed": {
327 "album": "FOREVER MY MOMENT",
328 "albumArtist": null,
329 "artist": "Tee Grizzley",
330 "currentTime": 17,
331 "duration": 182,
332 "isPlaying": false,
333 "isPodcast": false,
334 "originUrl": "https://listen.tidal.com/",
335 "scrobblingDisallowedReason": null,
336 "track": "Forever My Moment",
337 "trackArt": "https://resources.tidal.com/images/275251bf/9f03/46bf/9e46/3a3b0a67abe6/80x80.jpg",
338 "uniqueID": "434750253"
339 },
340 "processed": {
341 "album": "FOREVER MY MOMENT",
342 "albumArtist": null,
343 "artist": "Tee Grizzley",
344 "duration": 182,
345 "track": "Forever My Moment"
346 }
347 }
348 },
349 "eventName": "paused",
350 "time": 1747766997907
351 }
352 "#;
353
354 let result = serde_json::from_str::<ScrobbleRequest>(json);
355 assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err());
356 }
357
358 #[test]
359 fn test_spotify_nowplaying_request() {
360 let json = r#"
361 {
362 "data": {
363 "song": {
364 "connector": {
365 "hasNativeScrobbler": true,
366 "id": "spotify",
367 "js": "spotify.js",
368 "label": "Spotify",
369 "matches": [
370 "*://open.spotify.com/*"
371 ]
372 },
373 "controllerTabId": 2105804433,
374 "flags": {
375 "finishedProcessing": true,
376 "hasBlockedTag": false,
377 "isAlbumFetched": false,
378 "isCorrectedByUser": false,
379 "isLovedInService": null,
380 "isMarkedAsPlaying": true,
381 "isRegexEditedByUser": {
382 "album": false,
383 "albumArtist": false,
384 "artist": false,
385 "track": false
386 },
387 "isReplaying": false,
388 "isScrobbled": false,
389 "isSkipped": false,
390 "isValid": true
391 },
392 "metadata": {
393 "albumUrl": "https://www.last.fm/music/The+Weeknd/Hurry+Up+Tomorrow+(First+Press)",
394 "artistUrl": "https://www.last.fm/music/The+Weeknd",
395 "label": "Spotify",
396 "startTimestamp": 1747753805,
397 "trackArtUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/eadb0529b2c5066ebe7f53c52e329def.png",
398 "trackUrl": "https://www.last.fm/music/The+Weeknd/_/Given+Up+on+Me",
399 "userPlayCount": 0,
400 "userloved": false
401 },
402 "noRegex": {
403 "album": "Hurry Up Tomorrow",
404 "albumArtist": null,
405 "artist": "The Weeknd",
406 "duration": null,
407 "track": "Given Up On Me"
408 },
409 "parsed": {
410 "album": "Hurry Up Tomorrow",
411 "albumArtist": null,
412 "artist": "The Weeknd",
413 "currentTime": null,
414 "duration": 354,
415 "isPlaying": true,
416 "isPodcast": false,
417 "originUrl": null,
418 "scrobblingDisallowedReason": null,
419 "track": "Given Up On Me",
420 "trackArt": "https://i.scdn.co/image/ab67616d00001e02982320da137d0de34410df61",
421 "uniqueID": null
422 },
423 "processed": {
424 "album": "Hurry Up Tomorrow",
425 "albumArtist": null,
426 "artist": "The Weeknd",
427 "duration": 354,
428 "track": "Given Up on Me"
429 }
430 }
431 },
432 "eventName": "nowplaying",
433 "time": 1747753806195
434 }
435 "#;
436
437 let result = serde_json::from_str::<ScrobbleRequest>(json);
438 assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err());
439 }
440
441 #[test]
442 fn test_spotify_scrobble_request() {
443 let json = r#"
444 {
445 "data": {
446 "currentlyPlaying": true,
447 "song": {
448 "connector": {
449 "hasNativeScrobbler": true,
450 "id": "spotify",
451 "js": "spotify.js",
452 "label": "Spotify",
453 "matches": [
454 "*://open.spotify.com/*"
455 ]
456 },
457 "controllerTabId": 2105804433,
458 "flags": {
459 "finishedProcessing": true,
460 "hasBlockedTag": false,
461 "isAlbumFetched": false,
462 "isCorrectedByUser": false,
463 "isLovedInService": null,
464 "isMarkedAsPlaying": true,
465 "isRegexEditedByUser": {
466 "album": false,
467 "albumArtist": false,
468 "artist": false,
469 "track": false
470 },
471 "isReplaying": false,
472 "isScrobbled": false,
473 "isSkipped": false,
474 "isValid": true
475 },
476 "metadata": {
477 "artistUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory",
478 "label": "Spotify",
479 "startTimestamp": 1747753624,
480 "trackUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory/_/Never+Let+Me+Down",
481 "userPlayCount": 0,
482 "userloved": false
483 },
484 "noRegex": {
485 "album": "Never Let Me Down",
486 "albumArtist": null,
487 "artist": "VIZE, Tom Gregory",
488 "duration": null,
489 "track": "Never Let Me Down"
490 },
491 "parsed": {
492 "album": "Never Let Me Down",
493 "albumArtist": null,
494 "artist": "VIZE, Tom Gregory",
495 "currentTime": 76,
496 "duration": 153,
497 "isPlaying": true,
498 "isPodcast": false,
499 "originUrl": null,
500 "scrobblingDisallowedReason": null,
501 "track": "Never Let Me Down",
502 "trackArt": "https://i.scdn.co/image/ab67616d00001e02e33c4ba1bf5eecbbc7dddc85",
503 "uniqueID": null
504 },
505 "processed": {
506 "album": "Never Let Me Down",
507 "albumArtist": null,
508 "artist": "VIZE, Tom Gregory",
509 "duration": 153,
510 "track": "Never Let Me Down"
511 }
512 },
513 "songs": [
514 {
515 "connector": {
516 "hasNativeScrobbler": true,
517 "id": "spotify",
518 "js": "spotify.js",
519 "label": "Spotify",
520 "matches": [
521 "*://open.spotify.com/*"
522 ]
523 },
524 "controllerTabId": 2105804433,
525 "flags": {
526 "finishedProcessing": true,
527 "hasBlockedTag": false,
528 "isAlbumFetched": false,
529 "isCorrectedByUser": false,
530 "isLovedInService": null,
531 "isMarkedAsPlaying": true,
532 "isRegexEditedByUser": {
533 "album": false,
534 "albumArtist": false,
535 "artist": false,
536 "track": false
537 },
538 "isReplaying": false,
539 "isScrobbled": false,
540 "isSkipped": false,
541 "isValid": true
542 },
543 "metadata": {
544 "artistUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory",
545 "label": "Spotify",
546 "startTimestamp": 1747753624,
547 "trackUrl": "https://www.last.fm/music/VIZE,+Tom+Gregory/_/Never+Let+Me+Down",
548 "userPlayCount": 0,
549 "userloved": false
550 },
551 "noRegex": {
552 "album": "Never Let Me Down",
553 "albumArtist": null,
554 "artist": "VIZE, Tom Gregory",
555 "duration": null,
556 "track": "Never Let Me Down"
557 },
558 "parsed": {
559 "album": "Never Let Me Down",
560 "albumArtist": null,
561 "artist": "VIZE, Tom Gregory",
562 "currentTime": 76,
563 "duration": 153,
564 "isPlaying": true,
565 "isPodcast": false,
566 "originUrl": null,
567 "scrobblingDisallowedReason": null,
568 "track": "Never Let Me Down",
569 "trackArt": "https://i.scdn.co/image/ab67616d00001e02e33c4ba1bf5eecbbc7dddc85",
570 "uniqueID": null
571 },
572 "processed": {
573 "album": "Never Let Me Down",
574 "albumArtist": null,
575 "artist": "VIZE, Tom Gregory",
576 "duration": 153,
577 "track": "Never Let Me Down"
578 }
579 }
580 ]
581 },
582 "eventName": "scrobble",
583 "time": 1747753702338
584 }
585 "#;
586
587 let result = serde_json::from_str::<ScrobbleRequest>(json);
588 assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err());
589 }
590
591 #[test]
592 fn test_youtube_scrobble_request() {
593 let json = r#"
594 {
595 "eventName": "nowplaying",
596 "time": 1747899797294,
597 "data": {
598 "song": {
599 "parsed": {
600 "track": "Let It Talk To Me",
601 "artist": "Sean Paul x INNA",
602 "albumArtist": null,
603 "album": null,
604 "duration": 155,
605 "uniqueID": "nkRyAVQdqAA",
606 "currentTime": 11,
607 "isPlaying": true,
608 "isPodcast": false,
609 "originUrl": "https://youtu.be/nkRyAVQdqAA",
610 "scrobblingDisallowedReason": null,
611 "trackArt": null
612 },
613 "processed": {
614 "track": "Let It Talk To Me",
615 "artist": "Sean Paul x INNA",
616 "albumArtist": null,
617 "duration": 154.661
618 },
619 "noRegex": {
620 "track": "Let It Talk To Me",
621 "artist": "Sean Paul x INNA",
622 "albumArtist": null,
623 "duration": null
624 },
625 "flags": {
626 "isScrobbled": false,
627 "isCorrectedByUser": false,
628 "isRegexEditedByUser": {
629 "track": false,
630 "artist": false,
631 "album": false,
632 "albumArtist": false
633 },
634 "isAlbumFetched": true,
635 "isValid": true,
636 "isMarkedAsPlaying": true,
637 "isSkipped": false,
638 "isReplaying": false,
639 "hasBlockedTag": false,
640 "isLovedInService": null,
641 "finishedProcessing": true
642 },
643 "metadata": {
644 "userloved": false,
645 "startTimestamp": 1747899788,
646 "label": "YouTube",
647 "trackArtUrl": "https://coverartarchive.org/release/b74fe4b2-d633-4607-af93-b277b8b6a6b6/front-500",
648 "artistUrl": "https://www.last.fm/music/Sean+Paul+x+INNA",
649 "trackUrl": "https://www.last.fm/music/Sean+Paul+x+INNA/_/Let+It+Talk+To+Me",
650 "userPlayCount": 0
651 },
652 "connector": {
653 "label": "YouTube",
654 "matches": [
655 "*://www.youtube.com/*",
656 "*://m.youtube.com/*"
657 ],
658 "js": "youtube.js",
659 "id": "youtube",
660 "usesBlocklist": true
661 },
662 "controllerTabId": 2105807456
663 }
664 }
665}
666 "#;
667
668 let result = serde_json::from_str::<ScrobbleRequest>(json);
669 assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err());
670 }
671
672 #[test]
673 fn test_artists_parsing() {
674 let track = "Let It Talk To Me";
675 let artist = " Sean Paul x INNA";
676 let query = match artist.contains(" x ") {
677 true => {
678 let artists = artist
679 .split(" x ")
680 .map(|a| format!(r#"artist:"{}""#, a.trim()))
681 .collect::<Vec<_>>()
682 .join(" ");
683 format!(r#"track:"{}" {}"#, track, artists)
684 }
685 false => format!(r#"track:"{}" artist:"{}""#, track, artist.trim()),
686 };
687
688 assert_eq!(
689 query,
690 r#"track:"Let It Talk To Me" artist:"Sean Paul" artist:"INNA""#
691 );
692
693 let artist = "Sean Paul, INNA";
694 let track = "Let It Talk To Me";
695 match artist.contains(", ") {
696 true => {
697 let artists = artist
698 .split(", ")
699 .map(|a| format!(r#"artist:"{}""#, a.trim()))
700 .collect::<Vec<_>>()
701 .join(" ");
702 format!(r#"track:"{}" {}"#, track, artists)
703 }
704 false => format!(r#"track:"{}" artist:"{}""#, track, artist.trim()),
705 };
706
707 assert_eq!(
708 query,
709 r#"track:"Let It Talk To Me" artist:"Sean Paul" artist:"INNA""#
710 );
711 }
712
713 #[test]
714 fn test_kexp_scrobble_request() {
715 let json = r#"
716 {
717 "eventName": "nowplaying",
718 "time": 1749848844651,
719 "data": {
720 "song": {
721 "parsed": {
722 "track": "ENERGY",
723 "artist": "Disclosure",
724 "albumArtist": null,
725 "album": "ENERGY",
726 "duration": null,
727 "uniqueID": null,
728 "currentTime": null,
729 "isPlaying": true,
730 "trackArt": "https://ia803209.us.archive.org/7/items/mbid-6f1db1e3-71b3-4524-b0fe-c1e29f361dfe/mbid-6f1db1e3-71b3-4524-b0fe-c1e29f361dfe-26365923265_thumb250.jpg",
731 "isPodcast": false,
732 "originUrl": "https://www.kexp.org/",
733 "scrobblingDisallowedReason": null
734 },
735 "processed": {
736 "track": "ENERGY",
737 "artist": "Disclosure",
738 "albumArtist": null,
739 "album": "ENERGY",
740 "duration": null
741 },
742 "noRegex": {
743 "track": "ENERGY",
744 "artist": "Disclosure",
745 "albumArtist": null,
746 "album": "ENERGY",
747 "duration": null
748 },
749 "flags": {
750 "isScrobbled": false,
751 "isCorrectedByUser": false,
752 "isRegexEditedByUser": {
753 "track": false,
754 "artist": false,
755 "album": false,
756 "albumArtist": false
757 },
758 "isAlbumFetched": false,
759 "isValid": true,
760 "isMarkedAsPlaying": true,
761 "isSkipped": false,
762 "isReplaying": true,
763 "hasBlockedTag": false,
764 "isLovedInService": null,
765 "finishedProcessing": true
766 },
767 "metadata": {
768 "userloved": false,
769 "startTimestamp": 1749848842,
770 "label": "KEXP",
771 "trackArtUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/a18f5dadcc38ad3d264e74ce51d9cf08.png",
772 "artistUrl": "https://www.last.fm/music/Disclosure",
773 "trackUrl": "https://www.last.fm/music/Disclosure/_/ENERGY",
774 "albumUrl": "https://www.last.fm/music/Various+Artists/Festival+Dance",
775 "userPlayCount": 0
776 },
777 "connector": {
778 "label": "KEXP",
779 "matches": [
780 "*://*.kexp.org/*"
781 ],
782 "js": "kexp.js",
783 "id": "kexp"
784 },
785 "controllerTabId": 943739308
786 }
787 }
788}
789 "#;
790
791 let result = serde_json::from_str::<ScrobbleRequest>(json);
792 assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err());
793 }
794
795 #[test]
796 fn test_deezer_scrobble_request() {
797 let json = r#"
798 {
799 "eventName": "nowplaying",
800 "time": 1747899797294,
801 "data": {
802 "song": {
803 "parsed": {
804 "track": "Exile Is A Habit",
805 "artist": "Fox Stevenson",
806 "albumArtist": null,
807 "album": "Sunk Cost Fallacy",
808 "duration": 261,
809 "uniqueID": "3382194151",
810 "currentTime": null,
811 "isPlaying": true,
812 "trackArt": "https://cdn-images.dzcdn.net/images/cover/f8f25df28395fcc1b036982ad1475737/500x500.jpg",
813 "isPodcast": false,
814 "originUrl": "https://www.deezer.com/en/album/761728451",
815 "scrobblingDisallowedReason": null
816 },
817 "processed": {
818 "track": "Exile Is A Habit",
819 "artist": "Fox Stevenson",
820 "albumArtist": null,
821 "album": "Sunk Cost Fallacy",
822 "duration": 258
823 },
824 "noRegex": {
825 "track": "Exile Is A Habit",
826 "artist": "Fox Stevenson",
827 "albumArtist": null,
828 "album": "Sunk Cost Fallacy",
829 "duration": null
830 },
831 "flags": {
832 "isScrobbled": false,
833 "isCorrectedByUser": false,
834 "isRegexEditedByUser": {
835 "track": false,
836 "artist": false,
837 "album": false,
838 "albumArtist": false
839 },
840 "isAlbumFetched": false,
841 "isValid": true,
842 "isMarkedAsPlaying": false,
843 "isSkipped": false,
844 "isReplaying": false,
845 "hasBlockedTag": false,
846 "isLovedInService": null,
847 "finishedProcessing": true
848 },
849 "metadata": {
850 "startTimestamp": 1754673593,
851 "label": "Deezer",
852 "trackArtUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/b3eee782a80469085b2105fc63f0e534.png",
853 "artistUrl": "https://www.last.fm/music/Fox+Stevenson",
854 "trackUrl": "https://www.last.fm/music/Fox+Stevenson/_/Exile+Is+A+Habit",
855 "albumUrl": "https://www.last.fm/music/Fox+Stevenson/Exile+Is+A+Habit",
856 "userPlayCount": 0
857 },
858 "connector": {
859 "label": "Deezer",
860 "matches": [
861 "*://www.deezer.com/*"
862 ],
863 "js": "deezer.js",
864 "id": "deezer"
865 },
866 "controllerTabId": 2105807456
867 }
868 }
869}
870 "#;
871
872 let result = serde_json::from_str::<ScrobbleRequest>(json);
873 assert!(result.is_ok(), "Failed to parse JSON: {:?}", result.err());
874 }
875}