Skip to main content

koan_core/
scrobbling.rs

1//! Forwarding plays to ListenBrainz and Last.fm.
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::{
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
30/// Where ListenBrainz is reached in place of [`API`]: a stand-in for tests.
31static 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/// Send every ListenBrainz call in this process to `base` instead. For tests,
41/// which answer for ListenBrainz themselves.
42#[doc(hidden)]
43pub fn use_api(base: &str) {
44    *API_OVERRIDE.lock() = Some(base.trim_end_matches('/').to_owned());
45}
46
47/// Listens per submission. ListenBrainz takes up to 1000; a smaller batch
48/// keeps one rejected listen from holding back many good ones for long.
49const 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    /// Something was queued since the last pass over the outbox.
60    queued: bool,
61    /// `(user, track)` now-playing notices to send.
62    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
72/// Start the sender for the database at `db_path`, once per process. It
73/// begins by sending whatever was left queued.
74pub 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
86/// Plays were queued. Costs nothing where no sender runs.
87pub fn wake() {
88    PENDING.lock().queued = true;
89    RING.notify_one();
90}
91
92/// Tell `user`'s services that `track_id` started playing.
93pub fn now_playing(user: i64, track_id: i64) {
94    if STARTED.get().is_none() {
95        return;
96    }
97    let mut pending = PENDING.lock();
98    // Only the latest notice per account means anything.
99    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
162/// Nothing more can be sent for now. `wait` is how long the service asked
163/// for, when it said.
164struct 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
182/// Send everything queued, account by account. An account whose service
183/// cannot be reached is passed over for the rest of the pass, so it holds up
184/// no other; the pass then fails, and the sender tries again after a wait.
185fn 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
202/// Send one account's queue until it is empty, the credential is refused, or
203/// the service cannot be reached, which is the inner `Err` with how long the
204/// service asked to be left.
205fn 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        // A play of a track with no artist or title tells the service nothing,
224        // and Last.fm ignores one too old to scrobble.
225        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            // One listen it will not take fails the batch: send them singly
251            // to find it, and drop only what it rejects.
252            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    /// The token is not accepted.
351    Refused,
352    /// The listens are not accepted as they stand.
353    Rejected(String),
354    /// No answer worth acting on; try again later, after the wait if given.
355    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
402/// What a submission's status means. Any other client error is the request
403/// as it stands, which sending it again will not change; only rate limiting
404/// and the server's own errors are worth waiting out.
405fn 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/// Why a ListenBrainz account could not be connected. Each message is fit to
457/// show the person who pasted the token.
458#[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
468/// A pasted token, trimmed and checked with ListenBrainz: the token and the
469/// account it belongs to. Blocks on the network, so call it without a
470/// database connection held.
471pub 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
480/// Connect `user`'s ListenBrainz account with a token [`check_listenbrainz_token`]
481/// accepted, queue their history and wake the sender. Returns how many plays
482/// were queued, and the connection as stored.
483pub 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
504/// Check a ListenBrainz user token, returning the account name it belongs to.
505pub 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    /// Two accounts with a play waiting each, the first queued first.
595    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        // A month old: Last.fm would ignore it.
676        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}