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