forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1use std::{collections::BTreeMap, env};
2
3use anyhow::Error;
4use rand::Rng;
5use sqlx::{Pool, Postgres};
6
7use crate::{
8 auth::{decode_token, extract_did},
9 cache::Cache,
10 crypto::decrypt_aes_256_ctr,
11 listenbrainz::types::SubmitListensRequest,
12 musicbrainz::{
13 client::MusicbrainzClient, get_best_release_from_recordings, recording::Recording,
14 },
15 repo::{self},
16 rocksky,
17 spotify::{client::SpotifyClient, refresh_token},
18 types::{Scrobble, Track},
19 xata::user::User,
20};
21
22fn parse_batch(form: &BTreeMap<String, String>) -> Result<Vec<Scrobble>, Error> {
23 let mut result = vec![];
24 let mut index = 0;
25
26 loop {
27 let artist = form.get(&format!("artist[{}]", index));
28 let track = form.get(&format!("track[{}]", index));
29 let timestamp = form.get(&format!("timestamp[{}]", index));
30
31 if artist.is_none() || track.is_none() || timestamp.is_none() {
32 break;
33 }
34
35 let album = form
36 .get(&format!("album[{}]", index))
37 .cloned()
38 .map(|x| x.trim().to_string());
39 let context = form
40 .get(&format!("context[{}]", index))
41 .cloned()
42 .map(|x| x.trim().to_string());
43 let stream_id = form
44 .get(&format!("streamId[{}]", index))
45 .and_then(|s| s.trim().parse().ok());
46 let chosen_by_user = form
47 .get(&format!("chosenByUser[{}]", index))
48 .and_then(|s| s.trim().parse().ok());
49 let track_number = form
50 .get(&format!("trackNumber[{}]", index))
51 .and_then(|s| s.trim().parse().ok());
52 let mbid = form.get(&format!("mbid[{}]", index)).cloned();
53 let album_artist = form
54 .get(&format!("albumArtist[{}]", index))
55 .map(|x| x.trim().to_string());
56 let duration = form
57 .get(&format!("duration[{}]", index))
58 .and_then(|s| s.trim().parse().ok());
59
60 let timestamp = timestamp
61 .unwrap()
62 .trim()
63 .parse()
64 .unwrap_or(chrono::Utc::now().timestamp() as u64);
65
66 // validate timestamp, must be in the past (between 14 days before to present)
67 let now = chrono::Utc::now().timestamp() as u64;
68 if timestamp > now {
69 return Err(Error::msg("Timestamp is in the future"));
70 }
71
72 if timestamp < now - 14 * 24 * 60 * 60 {
73 return Err(Error::msg("Timestamp is too old"));
74 }
75
76 result.push(Scrobble {
77 artist: artist.unwrap().trim().to_string(),
78 track: track.unwrap().trim().to_string(),
79 timestamp,
80 album,
81 context,
82 stream_id,
83 chosen_by_user,
84 track_number,
85 mbid,
86 album_artist,
87 duration,
88 ignored: None,
89 });
90
91 index += 1;
92 }
93
94 Ok(result)
95}
96
97pub async fn scrobble(
98 pool: &Pool<Postgres>,
99 cache: &Cache,
100 mb_client: &MusicbrainzClient,
101 form: &BTreeMap<String, String>,
102) -> Result<Vec<Scrobble>, Error> {
103 let mut scrobbles = parse_batch(form)?;
104
105 if scrobbles.is_empty() {
106 return Err(Error::msg("No scrobbles found"));
107 }
108
109 let did = extract_did(pool, form).await?;
110
111 let spofity_tokens = repo::spotify_token::get_spotify_tokens(pool, 100).await?;
112
113 if spofity_tokens.is_empty() {
114 return Err(Error::msg("No Spotify tokens found"));
115 }
116
117 for scrobble in &mut scrobbles {
118 /*
119 0. check if scrobble is cached
120 1. if mbid is present, check if it exists in the database
121 2. if it exists, scrobble
122 3. if it doesn't exist, check if it exists in Musicbrainz (using mbid)
123 4. if it exists, get album art from spotify and scrobble
124 5. if it doesn't exist, check if it exists in Spotify
125 6. if it exists, scrobble
126 7. if it doesn't exist, check if it exists in Musicbrainz (using track and artist)
127 8. if it exists, scrobble
128 9. if it doesn't exist, skip unknown track
129 */
130 let key = format!(
131 "{} - {}",
132 scrobble.artist.to_lowercase(),
133 scrobble.track.to_lowercase()
134 );
135 let cached = cache.get(&key)?;
136 if cached.is_some() {
137 tracing::info!(key = %key, "Cached:");
138 let track = serde_json::from_str::<Track>(&cached.unwrap())?;
139 scrobble.album = Some(track.album.clone());
140 rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?;
141 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
142 continue;
143 }
144
145 if let Some(mbid) = &scrobble.mbid {
146 // let result = repo::track::get_track_by_mbid(pool, mbid).await?;
147 let result = mb_client.get_recording(mbid).await?;
148 tracing::info!(%scrobble.artist, %scrobble.track, "Musicbrainz (mbid)");
149 scrobble.album = Some(Track::from(result.clone()).album);
150 rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?;
151 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
152 continue;
153 }
154
155 let result = repo::track::get_track(pool, &scrobble.track, &scrobble.artist).await?;
156
157 if let Some(track) = result {
158 tracing::info!(artist = %scrobble.artist, track = %scrobble.track, "Xata (track)");
159 scrobble.album = Some(track.album.clone());
160 let album = repo::album::get_album_by_track_id(pool, &track.xata_id).await?;
161 let artist = repo::artist::get_artist_by_track_id(pool, &track.xata_id).await?;
162 let mut track: Track = track.into();
163 track.year = match album.year {
164 Some(year) => Some(year as u32),
165 None => match album.release_date.clone() {
166 Some(release_date) => {
167 let year = release_date.split("-").next();
168 year.and_then(|x| x.parse::<u32>().ok())
169 }
170 None => None,
171 },
172 };
173 track.release_date = album
174 .release_date
175 .map(|x| x.split("T").next().unwrap().to_string());
176 track.artist_picture = artist.picture.clone();
177
178 rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?;
179 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
180 continue;
181 }
182
183 // we need to pick a random token to avoid Spotify rate limiting
184 // and to avoid using the same token for all scrobbles
185 // this is a simple way to do it, but we can improve it later
186 // by using a more sophisticated algorithm
187 // or by using a token pool
188 let mut rng = rand::rng();
189 let random_index = rng.random_range(0..spofity_tokens.len());
190 let spotify_token = &spofity_tokens[random_index];
191
192 let spotify_token = decrypt_aes_256_ctr(
193 &spotify_token.refresh_token,
194 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?,
195 )?;
196
197 let spotify_token = refresh_token(&spotify_token).await?;
198 let spotify_client = SpotifyClient::new(&spotify_token.access_token);
199
200 let result = spotify_client
201 .search(&format!(
202 r#"track:"{}" artist:"{}""#,
203 scrobble.track, scrobble.artist
204 ))
205 .await?;
206
207 if let Some(track) = result.tracks.items.first() {
208 tracing::info!(artist = %scrobble.artist, track = %scrobble.track, "Spotify (track)");
209 scrobble.album = Some(track.album.name.clone());
210 let mut track = track.clone();
211
212 if let Some(album) = spotify_client.get_album(&track.album.id).await? {
213 track.album = album;
214 }
215
216 if let Some(artist) = spotify_client
217 .get_artist(&track.album.artists[0].id)
218 .await?
219 {
220 track.album.artists[0] = artist;
221 }
222
223 rocksky::scrobble(cache, &did, track.into(), scrobble.timestamp).await?;
224 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
225 continue;
226 }
227
228 let query = format!(
229 r#"recording:"{}" AND artist:"{}" AND status:Official"#,
230 scrobble.track, scrobble.artist
231 );
232 let result = mb_client.search(&query).await?;
233
234 if let Some(recording) = result.recordings.first() {
235 let result = mb_client.get_recording(&recording.id).await?;
236 tracing::info!(%scrobble.artist, %scrobble.track, "Musicbrainz (recording)");
237 scrobble.album = Some(Track::from(result.clone()).album);
238 rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?;
239 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
240 continue;
241 }
242
243 tracing::info!(artist = %scrobble.artist, track = %scrobble.track, "Track not found, skipping");
244 scrobble.ignored = Some(true);
245 }
246
247 Ok(scrobbles.clone())
248}
249
250pub async fn scrobble_v1(
251 pool: &Pool<Postgres>,
252 cache: &Cache,
253 mb_client: &MusicbrainzClient,
254 form: &BTreeMap<String, String>,
255) -> Result<(), Error> {
256 let session_id = form.get("s").unwrap().to_string();
257 let artist = form.get("a[0]").unwrap().to_string();
258 let track = form.get("t[0]").unwrap().to_string();
259 let timestamp = form.get("i[0]").unwrap().to_string();
260
261 let user = cache.get(&format!("lastfm:{}", session_id))?;
262 if user.is_none() {
263 return Err(Error::msg("Session ID not found"));
264 }
265
266 let user = user.unwrap();
267 let user = serde_json::from_str::<User>(&user)?;
268
269 let spofity_tokens = repo::spotify_token::get_spotify_tokens(pool, 100).await?;
270
271 if spofity_tokens.is_empty() {
272 return Err(Error::msg("No Spotify tokens found"));
273 }
274
275 let mut scrobble = Scrobble {
276 artist: artist.trim().to_string(),
277 track: track.trim().to_string(),
278 timestamp: timestamp.parse::<u64>()?,
279 album: None,
280 context: None,
281 stream_id: None,
282 chosen_by_user: None,
283 track_number: None,
284 mbid: None,
285 album_artist: None,
286 duration: None,
287 ignored: None,
288 };
289
290 let did = user.did.clone();
291
292 /*
293 0. check if scrobble is cached
294 1. if mbid is present, check if it exists in the database
295 2. if it exists, scrobble
296 3. if it doesn't exist, check if it exists in Musicbrainz (using mbid)
297 4. if it exists, get album art from spotify and scrobble
298 5. if it doesn't exist, check if it exists in Spotify
299 6. if it exists, scrobble
300 7. if it doesn't exist, check if it exists in Musicbrainz (using track and artist)
301 8. if it exists, scrobble
302 9. if it doesn't exist, skip unknown track
303 */
304 let key = format!(
305 "{} - {}",
306 scrobble.artist.to_lowercase(),
307 scrobble.track.to_lowercase()
308 );
309 let cached = cache.get(&key)?;
310 if cached.is_some() {
311 tracing::info!(key = %key, "Cached:");
312 let track = serde_json::from_str::<Track>(&cached.unwrap())?;
313 scrobble.album = Some(track.album.clone());
314 rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?;
315 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
316 return Ok(());
317 }
318
319 if let Some(mbid) = &scrobble.mbid {
320 // let result = repo::track::get_track_by_mbid(pool, mbid).await?;
321 let result = mb_client.get_recording(mbid).await?;
322 tracing::info!(%scrobble.artist, %scrobble.track, "Musicbrainz (mbid)");
323 scrobble.album = Some(Track::from(result.clone()).album);
324 rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?;
325 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
326 return Ok(());
327 }
328
329 let result = repo::track::get_track(pool, &scrobble.track, &scrobble.artist).await?;
330
331 if let Some(track) = result {
332 tracing::info!(artist = %scrobble.artist, track = %scrobble.track, "Xata (track)");
333 scrobble.album = Some(track.album.clone());
334 let album = repo::album::get_album_by_track_id(pool, &track.xata_id).await?;
335 let artist = repo::artist::get_artist_by_track_id(pool, &track.xata_id).await?;
336 let mut track: Track = track.into();
337 track.year = match album.year {
338 Some(year) => Some(year as u32),
339 None => match album.release_date.clone() {
340 Some(release_date) => {
341 let year = release_date.split("-").next();
342 year.and_then(|x| x.parse::<u32>().ok())
343 }
344 None => None,
345 },
346 };
347 track.release_date = album
348 .release_date
349 .map(|x| x.split("T").next().unwrap().to_string());
350 track.artist_picture = artist.picture.clone();
351
352 rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?;
353 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
354 return Ok(());
355 }
356
357 // we need to pick a random token to avoid Spotify rate limiting
358 // and to avoid using the same token for all scrobbles
359 // this is a simple way to do it, but we can improve it later
360 // by using a more sophisticated algorithm
361 // or by using a token pool
362 let mut rng = rand::rng();
363 let random_index = rng.random_range(0..spofity_tokens.len());
364 let spotify_token = &spofity_tokens[random_index];
365
366 let spotify_token = decrypt_aes_256_ctr(
367 &spotify_token.refresh_token,
368 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?,
369 )?;
370
371 let spotify_token = refresh_token(&spotify_token).await?;
372 let spotify_client = SpotifyClient::new(&spotify_token.access_token);
373
374 let result = spotify_client
375 .search(&format!(
376 r#"track:"{}" artist:"{}""#,
377 scrobble.track, scrobble.artist
378 ))
379 .await?;
380
381 if let Some(track) = result.tracks.items.first() {
382 let artists = track
383 .artists
384 .iter()
385 .map(|a| a.name.to_lowercase().clone())
386 .collect::<Vec<_>>()
387 .join(", ")
388 .to_lowercase();
389
390 // check if artists don't contain the scrobble artist (to avoid wrong matches)
391 if !artists.contains(&scrobble.artist.to_lowercase()) {
392 tracing::warn!(artist = %artist, track = ?track, "Artist mismatch, skipping");
393 } else {
394 tracing::info!(artist = %scrobble.artist, track = %scrobble.track, "Spotify (track)");
395 scrobble.album = Some(track.album.name.clone());
396 let mut track = track.clone();
397
398 if let Some(album) = spotify_client.get_album(&track.album.id).await? {
399 track.album = album;
400 }
401
402 if let Some(artist) = spotify_client
403 .get_artist(&track.album.artists[0].id)
404 .await?
405 {
406 track.album.artists[0] = artist;
407 }
408
409 rocksky::scrobble(cache, &did, track.into(), scrobble.timestamp).await?;
410 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
411 return Ok(());
412 }
413 }
414
415 let query = format!(
416 r#"recording:"{}" AND artist:"{}" AND status:Official"#,
417 scrobble.track, scrobble.artist
418 );
419 let result = search_musicbrainz_recording(&query, mb_client, &scrobble).await;
420 if let Err(e) = result {
421 tracing::warn!(artist = %scrobble.artist, track = %scrobble.track, "Musicbrainz search error: {}", e);
422 return Ok(());
423 }
424 let result = result.unwrap();
425 if let Some(recording) = result {
426 let result = mb_client.get_recording(&recording.id).await?;
427 tracing::info!(%scrobble.artist, %scrobble.track, "Musicbrainz (recording)");
428 scrobble.album = Some(Track::from(result.clone()).album);
429 rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?;
430 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
431 return Ok(());
432 }
433
434 tracing::info!(artist = %artist, track = %track, "Track not found, skipping");
435
436 Ok(())
437}
438
439pub async fn scrobble_listenbrainz(
440 pool: &Pool<Postgres>,
441 cache: &Cache,
442 mb_client: &MusicbrainzClient,
443 req: &SubmitListensRequest,
444 token: &str,
445) -> Result<(), Error> {
446 tracing::info!(req = ?req, "Listenbrainz submission");
447
448 if req.payload.is_empty() {
449 return Err(Error::msg("No payload found"));
450 }
451
452 let artist = req.payload[0].track_metadata.artist_name.clone();
453 let track = req.payload[0].track_metadata.track_name.clone();
454 let timestamp = match req.payload[0].listened_at {
455 Some(timestamp) => timestamp.to_string(),
456 None => chrono::Utc::now().timestamp().to_string(),
457 };
458
459 let did = match decode_token(token) {
460 Ok(claims) => claims.did,
461 Err(e) => {
462 let user = repo::user::get_user_by_apikey(pool, token)
463 .await?
464 .map(|user| user.did);
465 if let Some(did) = user {
466 did
467 } else {
468 return Err(Error::msg(format!(
469 "Failed to decode token: {} {}",
470 e, token
471 )));
472 }
473 }
474 };
475
476 let user = repo::user::get_user_by_did(pool, &did).await?;
477
478 if user.is_none() {
479 return Err(Error::msg("User not found"));
480 }
481
482 cache.setex(
483 &format!("listenbrainz:emby:{}:{}:{}", artist, track, did),
484 "1",
485 60 * 5, // 5 minutes
486 )?;
487
488 if cache
489 .get(&format!("listenbrainz:cache:{}:{}:{}", artist, track, did))?
490 .is_some()
491 {
492 tracing::info!(artist= %artist, track = %track, "Recently scrobbled, skipping");
493 return Ok(());
494 }
495
496 let spotify_user = repo::spotify_account::get_spotify_account(pool, &did).await?;
497 if let Some(spotify_user) = spotify_user {
498 if cache
499 .get(&format!("{}:current", spotify_user.email))?
500 .is_some()
501 {
502 tracing::info!(artist= %artist, track = %track, "Currently scrobbling, skipping");
503 return Ok(());
504 }
505 }
506
507 if cache.get(&format!("nowplaying:{}", did))?.is_some() {
508 tracing::info!(artist= %artist, track = %track, "Currently scrobbling, skipping");
509 return Ok(());
510 }
511
512 // set cache for 5 seconds to avoid duplicate scrobbles
513 cache.setex(
514 &format!("listenbrainz:cache:{}:{}:{}", artist, track, did),
515 "1",
516 5,
517 )?;
518
519 let spofity_tokens = repo::spotify_token::get_spotify_tokens(pool, 100).await?;
520
521 if spofity_tokens.is_empty() {
522 return Err(Error::msg("No Spotify tokens found"));
523 }
524
525 let mut scrobble = Scrobble {
526 artist: artist.trim().to_string(),
527 track: track.trim().to_string(),
528 timestamp: timestamp.parse::<u64>()?,
529 album: None,
530 context: None,
531 stream_id: None,
532 chosen_by_user: None,
533 track_number: None,
534 mbid: None,
535 album_artist: None,
536 duration: None,
537 ignored: None,
538 };
539
540 /*
541 0. check if scrobble is cached
542 1. if mbid is present, check if it exists in the database
543 2. if it exists, scrobble
544 3. if it doesn't exist, check if it exists in Musicbrainz (using mbid)
545 4. if it exists, get album art from spotify and scrobble
546 5. if it doesn't exist, check if it exists in Spotify
547 6. if it exists, scrobble
548 7. if it doesn't exist, check if it exists in Musicbrainz (using track and artist)
549 8. if it exists, scrobble
550 9. if it doesn't exist, skip unknown track
551 */
552 let key = format!(
553 "{} - {}",
554 scrobble.artist.to_lowercase(),
555 scrobble.track.to_lowercase()
556 );
557 let cached = cache.get(&key)?;
558 if cached.is_some() {
559 tracing::info!(key = %key, "Cached");
560 let track = serde_json::from_str::<Track>(&cached.unwrap())?;
561 scrobble.album = Some(track.album.clone());
562 rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?;
563 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
564 return Ok(());
565 }
566
567 if let Some(mbid) = &scrobble.mbid {
568 // let result = repo::track::get_track_by_mbid(pool, mbid).await?;
569 let result = mb_client.get_recording(mbid).await?;
570 tracing::info!("Musicbrainz (mbid)");
571 scrobble.album = Some(Track::from(result.clone()).album);
572 rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?;
573 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
574 return Ok(());
575 }
576
577 let result = repo::track::get_track(pool, &scrobble.track, &scrobble.artist).await?;
578
579 if let Some(track) = result {
580 tracing::info!(id = %track.xata_id, artist = %track.artist, album = %track.album, album_atist = %track.album_artist, album_uri = ?track.album_uri, artist_uri = ?track.artist_uri, "Xata (track)");
581 scrobble.album = Some(track.album.clone());
582 let album =
583 repo::album::get_album_by_uri(pool, &track.album_uri.clone().unwrap_or_default())
584 .await?;
585 let artist =
586 repo::artist::get_artist_by_uri(pool, &track.artist_uri.clone().unwrap_or_default())
587 .await?;
588 let mut track: Track = track.into();
589 track.year = match album.year {
590 Some(year) => Some(year as u32),
591 None => match album.release_date.clone() {
592 Some(release_date) => {
593 let year = release_date.split("-").next();
594 year.and_then(|x| x.parse::<u32>().ok())
595 }
596 None => None,
597 },
598 };
599 track.release_date = album
600 .release_date
601 .map(|x| x.split("T").next().unwrap().to_string());
602 track.artist_picture = artist.picture.clone();
603
604 rocksky::scrobble(cache, &did, track, scrobble.timestamp).await?;
605 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
606 return Ok(());
607 }
608
609 // we need to pick a random token to avoid Spotify rate limiting
610 // and to avoid using the same token for all scrobbles
611 // this is a simple way to do it, but we can improve it later
612 // by using a more sophisticated algorithm
613 // or by using a token pool
614 let mut rng = rand::rng();
615 let random_index = rng.random_range(0..spofity_tokens.len());
616 let spotify_token = &spofity_tokens[random_index];
617
618 let spotify_token = decrypt_aes_256_ctr(
619 &spotify_token.refresh_token,
620 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?,
621 )?;
622
623 let spotify_token = refresh_token(&spotify_token).await?;
624 let spotify_client = SpotifyClient::new(&spotify_token.access_token);
625
626 let result = spotify_client
627 .search(&format!(
628 r#"track:"{}" artist:"{}""#,
629 scrobble.track, scrobble.artist
630 ))
631 .await?;
632
633 if let Some(track) = result.tracks.items.first() {
634 let artists = track
635 .artists
636 .iter()
637 .map(|a| a.name.to_lowercase().clone())
638 .collect::<Vec<_>>()
639 .join(", ")
640 .to_lowercase();
641
642 // check if artists don't contain the scrobble artist (to avoid wrong matches)
643 if !artists.contains(&scrobble.artist.to_lowercase()) {
644 tracing::warn!(artist = %artist, track = ?track, "Artist mismatch, skipping");
645 } else {
646 tracing::info!("Spotify (track)");
647 scrobble.album = Some(track.album.name.clone());
648 let mut track = track.clone();
649
650 if let Some(album) = spotify_client.get_album(&track.album.id).await? {
651 track.album = album;
652 }
653
654 if let Some(artist) = spotify_client
655 .get_artist(&track.album.artists[0].id)
656 .await?
657 {
658 track.album.artists[0] = artist;
659 }
660
661 rocksky::scrobble(cache, &did, track.into(), scrobble.timestamp).await?;
662 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
663 return Ok(());
664 }
665 }
666
667 let query = format!(
668 r#"recording:"{}" AND artist:"{}" AND status:Official"#,
669 scrobble.track, scrobble.artist
670 );
671 let result = search_musicbrainz_recording(&query, mb_client, &scrobble).await;
672 if let Err(e) = result {
673 tracing::warn!(artist = %artist, track = %track, "Musicbrainz search error: {}", e);
674 return Ok(());
675 }
676 let result = result.unwrap();
677 if let Some(result) = result {
678 tracing::info!("Musicbrainz (recording)");
679 rocksky::scrobble(cache, &did, result.into(), scrobble.timestamp).await?;
680 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
681 return Ok(());
682 }
683
684 tracing::warn!(artist = %artist, track = %track, "Track not found, skipping");
685
686 Ok(())
687}
688
689async fn search_musicbrainz_recording(
690 query: &str,
691 mb_client: &MusicbrainzClient,
692 scrobble: &Scrobble,
693) -> Result<Option<Recording>, Error> {
694 let result = mb_client.search(&query).await;
695 if let Err(e) = result {
696 tracing::warn!(artist = %scrobble.artist, track = %scrobble.track, "Musicbrainz search error: {}", e);
697 return Ok(None);
698 }
699 let result = result.unwrap();
700
701 let release = get_best_release_from_recordings(&result, &scrobble.artist);
702
703 if let Some(release) = release {
704 let recording = result.recordings.into_iter().find(|r| {
705 r.releases
706 .as_ref()
707 .map(|releases| releases.iter().any(|rel| rel.id == release.id))
708 .unwrap_or(false)
709 });
710 if recording.is_none() {
711 tracing::warn!(artist = %scrobble.artist, track = %scrobble.track, "Recording not found in MusicBrainz result, skipping");
712 return Ok(None);
713 }
714 let recording = recording.unwrap();
715 let mut result = mb_client.get_recording(&recording.id).await?;
716 tracing::info!("Musicbrainz (recording)");
717 result.releases = Some(vec![release]);
718 return Ok(Some(result));
719 }
720
721 Ok(None)
722}