1use std::path::PathBuf;
14use std::sync::OnceLock;
15use std::time::{Duration, Instant};
16
17use parking_lot::{Condvar, Mutex};
18use serde_json::{Value, json};
19
20use crate::db::pool::Pool;
21use crate::db::queries::scrobbling::{
22 self as queries, LASTFM, LISTENBRAINZ, Listen, ScrobbleTarget,
23};
24
25pub mod lastfm;
26pub use lastfm::LastfmError;
27
28const API: &str = "https://api.listenbrainz.org";
29
30static API_OVERRIDE: Mutex<Option<String>> = Mutex::new(None);
32
33fn api() -> String {
34 API_OVERRIDE
35 .lock()
36 .clone()
37 .unwrap_or_else(|| API.to_owned())
38}
39
40#[doc(hidden)]
43pub fn use_api(base: &str) {
44 *API_OVERRIDE.lock() = Some(base.trim_end_matches('/').to_owned());
45}
46
47const BATCH: usize = 100;
50
51const FIRST_RETRY: Duration = Duration::from_secs(30);
52const LAST_RETRY: Duration = Duration::from_secs(60 * 60);
53
54const CLIENT: &str = "kōan";
55const VERSION: &str = env!("CARGO_PKG_VERSION");
56
57#[derive(Default)]
58struct Pending {
59 queued: bool,
61 notices: Vec<(i64, i64)>,
63}
64
65static PENDING: Mutex<Pending> = Mutex::new(Pending {
66 queued: false,
67 notices: Vec::new(),
68});
69static RING: Condvar = Condvar::new();
70static STARTED: OnceLock<()> = OnceLock::new();
71
72pub fn start(db_path: PathBuf) {
75 STARTED.get_or_init(|| {
76 PENDING.lock().queued = true;
77 let spawned = std::thread::Builder::new()
78 .name("koan-scrobble".into())
79 .spawn(move || run(Pool::new(db_path)));
80 if let Err(e) = spawned {
81 log::warn!("scrobbling: could not start the sender: {e}");
82 }
83 });
84}
85
86pub fn wake() {
88 PENDING.lock().queued = true;
89 RING.notify_one();
90}
91
92pub fn now_playing(user: i64, track_id: i64) {
94 if STARTED.get().is_none() {
95 return;
96 }
97 let mut pending = PENDING.lock();
98 pending.notices.retain(|&(u, _)| u != user);
100 pending.notices.push((user, track_id));
101 drop(pending);
102 RING.notify_one();
103}
104
105fn run(pool: Pool) {
106 let http = reqwest::blocking::Client::builder()
107 .user_agent(concat!(
108 "koan/",
109 env!("CARGO_PKG_VERSION"),
110 " (https://github.com/radiosilence/koan)"
111 ))
112 .timeout(Duration::from_secs(30))
113 .build()
114 .unwrap_or_else(|_| reqwest::blocking::Client::new());
115 let mut retry = FIRST_RETRY;
116 let mut not_before: Option<Instant> = None;
117 loop {
118 let (drain, notices) = {
119 let mut pending = PENDING.lock();
120 loop {
121 let ready = pending.queued && not_before.is_none_or(|t| Instant::now() >= t);
122 if ready || !pending.notices.is_empty() {
123 break;
124 }
125 match not_before {
126 Some(t) if pending.queued => {
127 RING.wait_until(&mut pending, t);
128 }
129 _ => RING.wait(&mut pending),
130 }
131 }
132 let drain = pending.queued && not_before.is_none_or(|t| Instant::now() >= t);
133 if drain {
134 pending.queued = false;
135 }
136 (drain, std::mem::take(&mut pending.notices))
137 };
138 for (user, track) in notices {
139 send_notice(&pool, &http, user, track);
140 }
141 if !drain {
142 continue;
143 }
144 match send_queued(&pool, &mut |target, kind, listens| {
145 submit(&http, target, kind, listens)
146 }) {
147 Ok(()) => {
148 retry = FIRST_RETRY;
149 not_before = None;
150 }
151 Err(Unsent { wait }) => {
152 let wait = wait.unwrap_or(retry).max(Duration::from_secs(1));
153 log::info!("scrobbling: trying again in {}s", wait.as_secs());
154 retry = (retry * 2).min(LAST_RETRY);
155 not_before = Some(Instant::now() + wait);
156 PENDING.lock().queued = true;
157 }
158 }
159 }
160}
161
162struct Unsent {
165 wait: Option<Duration>,
166}
167
168impl From<crate::db::connection::DbError> for Unsent {
169 fn from(e: crate::db::connection::DbError) -> Self {
170 log::warn!("scrobbling: {e}");
171 Unsent { wait: None }
172 }
173}
174
175impl From<rusqlite::Error> for Unsent {
176 fn from(e: rusqlite::Error) -> Self {
177 log::warn!("scrobbling: {e}");
178 Unsent { wait: None }
179 }
180}
181
182fn send_queued(
186 pool: &Pool,
187 send: &mut impl FnMut(&ScrobbleTarget, ListenType, &[&Listen]) -> Sent,
188) -> Result<(), Unsent> {
189 let targets = queries::targets(&pool.get()?.conn)?;
190 let mut unreachable: Option<Unsent> = None;
191 for target in targets {
192 if let Err(wait) = drain(pool, &target, send)? {
193 let longest = unreachable.and_then(|u| u.wait).max(wait);
194 unreachable = Some(Unsent { wait: longest });
195 }
196 }
197 unreachable.map_or(Ok(()), Err)
198}
199
200type Sent = Result<(), Failure>;
201
202fn drain(
206 pool: &Pool,
207 target: &ScrobbleTarget,
208 send: &mut impl FnMut(&ScrobbleTarget, ListenType, &[&Listen]) -> Sent,
209) -> Result<Result<(), Option<Duration>>, Unsent> {
210 let (name, batch_size) = match target.service.as_str() {
211 LISTENBRAINZ => ("ListenBrainz", BATCH),
212 LASTFM => ("Last.fm", lastfm::BATCH),
213 _ => {
214 refuse(pool, target, "kōan cannot send to this service.")?;
215 return Ok(Ok(()));
216 }
217 };
218 loop {
219 let batch = queries::queued(&pool.get()?.conn, target, batch_size)?;
220 if batch.is_empty() {
221 return Ok(Ok(()));
222 }
223 let oldest = lastfm::oldest_taken(&target.service);
226 let (sendable, blank): (Vec<_>, Vec<_>) = batch.into_iter().partition(|q| {
227 !q.listen.artist.trim().is_empty()
228 && !q.listen.title.trim().is_empty()
229 && oldest.is_none_or(|t| q.listen.played_at >= t)
230 });
231 if !blank.is_empty() {
232 let ids: Vec<i64> = blank.iter().map(|q| q.outbox_id).collect();
233 queries::dequeue(&pool.get()?.conn, &ids)?;
234 }
235 if sendable.is_empty() {
236 continue;
237 }
238 let listens: Vec<&Listen> = sendable.iter().map(|q| &q.listen).collect();
239 let ids: Vec<i64> = sendable.iter().map(|q| q.outbox_id).collect();
240 match send(target, ListenType::of(listens.len()), &listens) {
241 Ok(()) => queries::dequeue(&pool.get()?.conn, &ids)?,
242 Err(Failure::Refused) => {
243 refuse(
244 pool,
245 target,
246 &format!("{name} refused the credential. Connect again."),
247 )?;
248 return Ok(Ok(()));
249 }
250 Err(Failure::Rejected(why)) => {
253 for (listen, id) in listens.iter().zip(&ids) {
254 match send(target, ListenType::Single, &[listen]) {
255 Ok(()) => {}
256 Err(Failure::Rejected(_)) => {
257 log::info!(
258 "scrobbling: {name} rejected {} – {}: {why}",
259 listen.artist,
260 listen.title
261 );
262 }
263 Err(Failure::Refused) => {
264 refuse(pool, target, &format!("{name} refused the credential."))?;
265 return Ok(Ok(()));
266 }
267 Err(Failure::Unreachable(wait)) => return Ok(Err(wait)),
268 }
269 queries::dequeue(&pool.get()?.conn, &[*id])?;
270 }
271 }
272 Err(Failure::Unreachable(wait)) => return Ok(Err(wait)),
273 }
274 }
275}
276
277fn refuse(pool: &Pool, target: &ScrobbleTarget, why: &str) -> Result<(), Unsent> {
278 log::info!(
279 "scrobbling: stopped sending for user {}: {why}",
280 target.user_id
281 );
282 queries::refuse(&pool.get()?.conn, target.user_id, &target.service, why)?;
283 Ok(())
284}
285
286fn send_notice(pool: &Pool, http: &reqwest::blocking::Client, user: i64, track: i64) {
287 let found = pool.get().ok().and_then(|db| {
288 let targets: Vec<ScrobbleTarget> = [LISTENBRAINZ, LASTFM]
289 .into_iter()
290 .filter_map(|service| {
291 let token = queries::target(&db.conn, user, service).ok()??;
292 Some(ScrobbleTarget {
293 user_id: user,
294 service: service.to_owned(),
295 token,
296 })
297 })
298 .collect();
299 if targets.is_empty() {
300 return None;
301 }
302 let listen = queries::now_playing(&db.conn, track).ok()??;
303 Some((targets, listen))
304 });
305 let Some((targets, listen)) = found else {
306 return;
307 };
308 if listen.artist.trim().is_empty() || listen.title.trim().is_empty() {
309 return;
310 }
311 for target in targets {
312 if let Err(Failure::Unreachable(_) | Failure::Rejected(_)) =
313 submit(http, &target, ListenType::PlayingNow, &[&listen])
314 {
315 log::debug!(
316 "scrobbling: now playing for user {user} not sent to {}",
317 target.service
318 );
319 }
320 }
321}
322
323#[derive(Debug, Clone, Copy, PartialEq)]
324enum ListenType {
325 Single,
326 Import,
327 PlayingNow,
328}
329
330impl ListenType {
331 fn of(count: usize) -> Self {
332 if count == 1 {
333 Self::Single
334 } else {
335 Self::Import
336 }
337 }
338
339 fn name(self) -> &'static str {
340 match self {
341 Self::Single => "single",
342 Self::Import => "import",
343 Self::PlayingNow => "playing_now",
344 }
345 }
346}
347
348#[derive(Debug)]
349enum Failure {
350 Refused,
352 Rejected(String),
354 Unreachable(Option<Duration>),
356}
357
358fn submit(
359 http: &reqwest::blocking::Client,
360 target: &ScrobbleTarget,
361 kind: ListenType,
362 listens: &[&Listen],
363) -> Result<(), Failure> {
364 match target.service.as_str() {
365 LISTENBRAINZ => submit_listenbrainz(http, &target.token, kind, listens),
366 LASTFM => lastfm::submit(http, &target.token, kind == ListenType::PlayingNow, listens),
367 _ => Err(Failure::Rejected("a service kōan cannot send to".into())),
368 }
369}
370
371fn submit_listenbrainz(
372 http: &reqwest::blocking::Client,
373 token: &str,
374 kind: ListenType,
375 listens: &[&Listen],
376) -> Result<(), Failure> {
377 let body = json!({
378 "listen_type": kind.name(),
379 "payload": listens
380 .iter()
381 .map(|l| listen_json(l, kind != ListenType::PlayingNow))
382 .collect::<Vec<_>>(),
383 });
384 let resp = http
385 .post(format!("{}/1/submit-listens", api()))
386 .header("Authorization", format!("Token {token}"))
387 .json(&body)
388 .send()
389 .map_err(|e| {
390 log::info!("scrobbling: ListenBrainz unreachable: {}", e.without_url());
391 Failure::Unreachable(None)
392 })?;
393 let status = resp.status().as_u16();
394 let wait = resp
395 .headers()
396 .get("X-RateLimit-Reset-In")
397 .and_then(|v| v.to_str().ok()?.parse().ok())
398 .map(Duration::from_secs);
399 outcome(status, wait, || resp.text().unwrap_or_default())
400}
401
402fn outcome(status: u16, wait: Option<Duration>, body: impl FnOnce() -> String) -> Sent {
406 match status {
407 200..=299 => Ok(()),
408 401 => Err(Failure::Refused),
409 429 => Err(Failure::Unreachable(wait)),
410 400..=499 => Err(Failure::Rejected(format!("{status}: {}", body()))),
411 _ => Err(Failure::Unreachable(None)),
412 }
413}
414
415fn listen_json(listen: &Listen, with_time: bool) -> Value {
416 let mut info = json!({
417 "media_player": CLIENT,
418 "submission_client": CLIENT,
419 "submission_client_version": VERSION,
420 });
421 if let Some(d) = listen.duration_ms.filter(|&d| d > 0) {
422 info["duration_ms"] = json!(d);
423 }
424 if let Some(n) = listen.track_number.filter(|&n| n > 0) {
425 info["tracknumber"] = json!(n);
426 }
427 if let Some(id) = &listen.recording_mbid {
428 info["recording_mbid"] = json!(id);
429 }
430 if let Some(id) = &listen.release_mbid {
431 info["release_mbid"] = json!(id);
432 }
433 let mut meta = json!({
434 "artist_name": listen.artist,
435 "track_name": listen.title,
436 "additional_info": info,
437 });
438 if let Some(album) = &listen.album {
439 meta["release_name"] = json!(album);
440 }
441 let mut out = json!({ "track_metadata": meta });
442 if with_time {
443 out["listened_at"] = json!(listen.played_at);
444 }
445 out
446}
447
448#[derive(Debug, thiserror::Error)]
449pub enum TokenError {
450 #[error("ListenBrainz does not accept this token.")]
451 Invalid,
452 #[error("ListenBrainz could not be reached. Try again later.")]
453 Unreachable,
454}
455
456#[derive(Debug, thiserror::Error)]
459pub enum ConnectError {
460 #[error("Paste the user token from your ListenBrainz settings.")]
461 NoToken,
462 #[error(transparent)]
463 Token(#[from] TokenError),
464 #[error("The connection could not be saved.")]
465 Saving,
466}
467
468pub fn check_listenbrainz_token(pasted: &str) -> Result<(String, String), ConnectError> {
472 let token = pasted.trim();
473 if token.is_empty() || token.len() > 200 {
474 return Err(ConnectError::NoToken);
475 }
476 let name = validate_listenbrainz_token(token)?;
477 Ok((token.to_owned(), name))
478}
479
480pub fn connect_listenbrainz(
484 conn: &rusqlite::Connection,
485 user: i64,
486 token: &str,
487 account_name: &str,
488) -> Result<(usize, queries::ScrobbleService), ConnectError> {
489 let saved = queries::connect(conn, user, LISTENBRAINZ, token, account_name, 0)
490 .and_then(|queued| Ok((queued, queries::service(conn, user, LISTENBRAINZ)?)));
491 match saved {
492 Ok((queued, Some(service))) => {
493 wake();
494 Ok((queued, service))
495 }
496 Ok((_, None)) => Err(ConnectError::Saving),
497 Err(e) => {
498 log::warn!("scrobbling: saving a connection failed: {e}");
499 Err(ConnectError::Saving)
500 }
501 }
502}
503
504pub fn validate_listenbrainz_token(token: &str) -> Result<String, TokenError> {
506 let http = reqwest::blocking::Client::builder()
507 .timeout(Duration::from_secs(15))
508 .build()
509 .map_err(|_| TokenError::Unreachable)?;
510 let resp = http
511 .get(format!("{}/1/validate-token", api()))
512 .header("Authorization", format!("Token {token}"))
513 .send()
514 .map_err(|_| TokenError::Unreachable)?;
515 if resp.status().as_u16() == 401 {
516 return Err(TokenError::Invalid);
517 }
518 if !resp.status().is_success() {
519 return Err(TokenError::Unreachable);
520 }
521 let body: Value = resp.json().map_err(|_| TokenError::Unreachable)?;
522 match (body["valid"].as_bool(), body["user_name"].as_str()) {
523 (Some(true), Some(name)) => Ok(name.to_owned()),
524 _ => Err(TokenError::Invalid),
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 fn listen() -> Listen {
533 Listen {
534 played_at: 1_700_000_000,
535 title: "Archangel".into(),
536 artist: "Burial".into(),
537 album: Some("Untrue".into()),
538 duration_ms: Some(238_000),
539 track_number: Some(2),
540 recording_mbid: Some("rec".into()),
541 release_mbid: None,
542 }
543 }
544
545 #[test]
546 fn a_listen_carries_what_is_known() {
547 let v = listen_json(&listen(), true);
548 assert_eq!(v["listened_at"], 1_700_000_000);
549 let meta = &v["track_metadata"];
550 assert_eq!(meta["artist_name"], "Burial");
551 assert_eq!(meta["track_name"], "Archangel");
552 assert_eq!(meta["release_name"], "Untrue");
553 let info = &meta["additional_info"];
554 assert_eq!(info["duration_ms"], 238_000);
555 assert_eq!(info["tracknumber"], 2);
556 assert_eq!(info["recording_mbid"], "rec");
557 assert!(info.get("release_mbid").is_none());
558 }
559
560 #[test]
561 fn now_playing_has_no_time() {
562 let mut l = listen();
563 l.album = None;
564 let v = listen_json(&l, false);
565 assert!(v.get("listened_at").is_none());
566 assert!(v["track_metadata"].get("release_name").is_none());
567 }
568
569 #[test]
570 fn client_errors_reject_the_batch_and_server_errors_wait() {
571 let body = String::new;
572 assert!(matches!(
573 outcome(403, None, body),
574 Err(Failure::Rejected(_))
575 ));
576 assert!(matches!(
577 outcome(413, None, body),
578 Err(Failure::Rejected(_))
579 ));
580 assert!(matches!(
581 outcome(400, None, body),
582 Err(Failure::Rejected(_))
583 ));
584 assert!(matches!(outcome(401, None, body), Err(Failure::Refused)));
585 let wait = Some(Duration::from_secs(9));
586 assert!(matches!(outcome(429, wait, body), Err(Failure::Unreachable(w)) if w == wait));
587 assert!(matches!(
588 outcome(503, None, body),
589 Err(Failure::Unreachable(None))
590 ));
591 assert!(outcome(200, None, body).is_ok());
592 }
593
594 fn two_accounts() -> (tempfile::TempDir, Pool) {
596 use crate::db::queries::{SOURCE_SUBSONIC, record_plays_at, sample_meta, upsert_track};
597 let dir = tempfile::tempdir().unwrap();
598 let path = dir.path().join("koan.db");
599 let db = crate::db::connection::Database::open(&path).unwrap();
600 db.conn
601 .execute_batch(
602 "INSERT INTO users (id, username, password_hash, role) VALUES
603 (1, 'stuck', 'x', 'user'), (2, 'fine', 'x', 'user');",
604 )
605 .unwrap();
606 upsert_track(&db.conn, &sample_meta("Archangel", "Burial", "Untrue")).unwrap();
607 let track: i64 = db
608 .conn
609 .query_row("SELECT id FROM tracks", [], |r| r.get(0))
610 .unwrap();
611 for (user, token) in [(1, "stuck"), (2, "fine")] {
612 queries::connect(&db.conn, user, LISTENBRAINZ, token, token, 0).unwrap();
613 record_plays_at(&db.conn, user, &[(track, 100 + user)], SOURCE_SUBSONIC).unwrap();
614 }
615 (dir, Pool::new(path))
616 }
617
618 fn pending(pool: &Pool, user: i64) -> i64 {
619 queries::services(&pool.get().unwrap().conn, user).unwrap()[0].pending
620 }
621
622 #[test]
623 fn an_unreachable_account_holds_up_no_other() {
624 let (_dir, pool) = two_accounts();
625 let mut sent = Vec::new();
626 let result = send_queued(&pool, &mut |target, _, listens| {
627 let token = target.token.as_str();
628 if token == "stuck" {
629 return Err(Failure::Unreachable(Some(Duration::from_secs(5))));
630 }
631 sent.push((token.to_owned(), listens.len()));
632 Ok(())
633 });
634 let Err(Unsent { wait }) = result else {
635 panic!("the pass should fail for the stuck account")
636 };
637 assert_eq!(wait, Some(Duration::from_secs(5)));
638 assert_eq!(sent, vec![("fine".to_owned(), 1)]);
639 assert_eq!((pending(&pool, 1), pending(&pool, 2)), (1, 0));
640 }
641
642 #[test]
643 fn a_rejected_listen_is_dropped_and_the_queue_moves_on() {
644 let (_dir, pool) = two_accounts();
645 let result = send_queued(&pool, &mut |target, _, _| {
646 if target.token == "stuck" {
647 return Err(Failure::Rejected("403: forbidden".into()));
648 }
649 Ok(())
650 });
651 assert!(result.is_ok());
652 assert_eq!((pending(&pool, 1), pending(&pool, 2)), (0, 0));
653 }
654
655 #[test]
656 fn lastfm_is_sent_in_batches_of_fifty_and_never_what_it_would_ignore() {
657 use crate::db::queries::{SOURCE_SUBSONIC, record_plays_at, sample_meta, upsert_track};
658 let dir = tempfile::tempdir().unwrap();
659 let path = dir.path().join("koan.db");
660 let db = crate::db::connection::Database::open(&path).unwrap();
661 db.conn
662 .execute(
663 "INSERT INTO users (id, username, password_hash, role) VALUES (1, 'u', 'x', 'user')",
664 [],
665 )
666 .unwrap();
667 upsert_track(&db.conn, &sample_meta("Archangel", "Burial", "Untrue")).unwrap();
668 let track: i64 = db
669 .conn
670 .query_row("SELECT id FROM tracks", [], |r| r.get(0))
671 .unwrap();
672 queries::connect(&db.conn, 1, LASTFM, "session", "fmuser", 0).unwrap();
673 let now = crate::auth::now_unix() as i64;
674 let mut plays: Vec<(i64, i64)> = (0..120).map(|i| (track, now - 100 - i)).collect();
675 plays.push((track, now - 30 * 24 * 3600));
677 record_plays_at(&db.conn, 1, &plays, SOURCE_SUBSONIC).unwrap();
678
679 let pool = Pool::new(path);
680 let mut sizes = Vec::new();
681 let result = send_queued(&pool, &mut |target, _, listens| {
682 assert_eq!(
683 (target.service.as_str(), target.token.as_str()),
684 (LASTFM, "session")
685 );
686 sizes.push(listens.len());
687 Ok(())
688 });
689 assert!(result.is_ok());
690 assert_eq!(sizes, vec![50, 50, 20]);
691 assert_eq!(pending(&pool, 1), 0, "the stale play is dropped, not held");
692 }
693
694 #[test]
695 fn several_listens_are_an_import() {
696 assert_eq!(ListenType::of(1), ListenType::Single);
697 assert_eq!(ListenType::of(2).name(), "import");
698 }
699}