Skip to main content

koan_core/
scrobbling.rs

1//! Forwarding plays to ListenBrainz.
2//!
3//! A server records every play an account reports. Plays bound for a service
4//! wait in `scrobble_outbox` (see `db::queries::scrobbling`), so they survive a
5//! restart or an outage. One thread, `koan-scrobble`, sends them: it sleeps
6//! until [`wake`] says something was queued, sends everything waiting in
7//! batches, and goes back to sleep. When the service cannot be reached it
8//! waits out a growing delay before trying again, and only then.
9//!
10//! Now-playing notices are not queued. One that cannot be sent is dropped:
11//! by the time the service answers again, the track has moved on.
12
13use 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
25/// Where ListenBrainz is reached in place of [`API`]: a stand-in for tests.
26static 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/// Send every ListenBrainz call in this process to `base` instead. For tests,
36/// which answer for ListenBrainz themselves.
37#[doc(hidden)]
38pub fn use_api(base: &str) {
39    *API_OVERRIDE.lock() = Some(base.trim_end_matches('/').to_owned());
40}
41
42/// Listens per submission. ListenBrainz takes up to 1000; a smaller batch
43/// keeps one rejected listen from holding back many good ones for long.
44const 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    /// Something was queued since the last pass over the outbox.
55    queued: bool,
56    /// `(user, track)` now-playing notices to send.
57    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
67/// Start the sender for the database at `db_path`, once per process. It
68/// begins by sending whatever was left queued.
69pub 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
81/// Plays were queued. Costs nothing where no sender runs.
82pub fn wake() {
83    PENDING.lock().queued = true;
84    RING.notify_one();
85}
86
87/// Tell `user`'s services that `track_id` started playing.
88pub fn now_playing(user: i64, track_id: i64) {
89    if STARTED.get().is_none() {
90        return;
91    }
92    let mut pending = PENDING.lock();
93    // Only the latest notice per account means anything.
94    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
157/// Nothing more can be sent for now. `wait` is how long the service asked
158/// for, when it said.
159struct 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
177/// Send everything queued, account by account. An account whose service
178/// cannot be reached is passed over for the rest of the pass, so it holds up
179/// no other; the pass then fails, and the sender tries again after a wait.
180fn 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
197/// Send one account's queue until it is empty, the credential is refused, or
198/// the service cannot be reached, which is the inner `Err` with how long the
199/// service asked to be left.
200fn 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        // A play of a track with no artist or title tells the service nothing.
215        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            // One listen it will not take fails the batch: send them singly
238            // to find it, and drop only what it rejects.
239            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    /// The token is not accepted.
318    Refused,
319    /// The listens are not accepted as they stand.
320    Rejected(String),
321    /// No answer worth acting on; try again later, after the wait if given.
322    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
356/// What a submission's status means. Any other client error is the request
357/// as it stands, which sending it again will not change; only rate limiting
358/// and the server's own errors are worth waiting out.
359fn 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/// Why a ListenBrainz account could not be connected. Each message is fit to
411/// show the person who pasted the token.
412#[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
422/// A pasted token, trimmed and checked with ListenBrainz: the token and the
423/// account it belongs to. Blocks on the network, so call it without a
424/// database connection held.
425pub 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
434/// Connect `user`'s ListenBrainz account with a token [`check_listenbrainz_token`]
435/// accepted, queue their history and wake the sender. Returns how many plays
436/// were queued, and the connection as stored.
437pub 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
458/// Check a ListenBrainz user token, returning the account name it belongs to.
459pub 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    /// Two accounts with a play waiting each, the first queued first.
549    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}