From bf01d5fe299606fc9e376df43df2ac8aa74896fa Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:51:16 +0300 Subject: [PATCH 1/6] feat(integrations): port the scrobbling clients, listen rules and offline queue --- src-tauri/Cargo.lock | 148 +++++ src-tauri/Cargo.toml | 7 +- src-tauri/migrations/0020_scrobble_queue.sql | 27 + src-tauri/src/errors/mod.rs | 26 +- src-tauri/src/scrobble/audioscrobbler.rs | 364 ++++++++++++ src-tauri/src/scrobble/credentials.rs | 189 ++++++ src-tauri/src/scrobble/listenbrainz.rs | 215 +++++++ src-tauri/src/scrobble/mod.rs | 21 + src-tauri/src/scrobble/models.rs | 77 +++ src-tauri/src/scrobble/queue.rs | 258 ++++++++ src-tauri/src/scrobble/rules.rs | 143 +++++ src-tauri/src/scrobble/scrobbler.rs | 583 +++++++++++++++++++ src-tauri/src/scrobble/worker.rs | 56 ++ 13 files changed, 2112 insertions(+), 2 deletions(-) create mode 100644 src-tauri/migrations/0020_scrobble_queue.sql create mode 100644 src-tauri/src/scrobble/audioscrobbler.rs create mode 100644 src-tauri/src/scrobble/credentials.rs create mode 100644 src-tauri/src/scrobble/listenbrainz.rs create mode 100644 src-tauri/src/scrobble/mod.rs create mode 100644 src-tauri/src/scrobble/models.rs create mode 100644 src-tauri/src/scrobble/queue.rs create mode 100644 src-tauri/src/scrobble/rules.rs create mode 100644 src-tauri/src/scrobble/scrobbler.rs create mode 100644 src-tauri/src/scrobble/worker.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 69e0a99b..8cff2bd4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -88,6 +88,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arbitrary" version = "1.5.0" @@ -373,6 +384,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -520,6 +540,15 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.9.0" @@ -1479,9 +1508,11 @@ dependencies = [ "hmac", "icu_properties", "icu_segmenter", + "keyring", "libloading", "local-ip-address", "lofty", + "md-5", "mp4", "objc2", "rand 0.10.3", @@ -2468,6 +2499,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ + "block-padding", "hybrid-array", ] @@ -2706,6 +2738,27 @@ dependencies = [ "serde", ] +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -3094,6 +3147,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3104,6 +3171,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3119,6 +3195,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -4019,6 +4105,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4192,6 +4279,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4267,6 +4360,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.1", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4414,6 +4526,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.24.0" @@ -6506,6 +6630,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -6958,6 +7095,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.19.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 13aa6d26..ce9bc7ea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,7 +51,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" # HTTP Client -reqwest = { version = "0.13.3", features = ["stream", "json", "brotli", "gzip", "rustls"] } +reqwest = { version = "0.13.3", features = ["stream", "json", "form", "brotli", "gzip", "rustls"] } mp4 = "0.14" webm-iterable = "0.6.2" @@ -76,6 +76,11 @@ hmac = "0.13" sha2 = "0.11" rand = "0.10" zeroize = { version = "1", features = ["derive"] } +# Scrobbling: Last.fm's api_sig is an MD5 digest, and session keys and tokens live in the OS +# keychain (macOS Keychain, Windows Credential Manager, the Secret Service on Linux). +md-5 = "0.11" +keyring = "4" + # Local Sync (P2P) — transport: plaintext WebSocket (payload is AES-GCM encrypted), gzip # compression (parity with Android java.util.zip), and LAN IP discovery for the QR. tokio-tungstenite = "0.30" diff --git a/src-tauri/migrations/0020_scrobble_queue.sql b/src-tauri/migrations/0020_scrobble_queue.sql new file mode 100644 index 00000000..bd4d724f --- /dev/null +++ b/src-tauri/migrations/0020_scrobble_queue.sql @@ -0,0 +1,27 @@ +-- Listens and loves waiting to reach Last.fm, Libre.fm or ListenBrainz (Flow for Android's +-- ScrobbleStore queues). A listen is queued the moment it counts and leaves once the service +-- accepts it, so quitting or going offline loses nothing. Device-local, never synced or backed up. +-- service: LASTFM, LIBREFM or LISTENBRAINZ. Each service keeps at most 2,000 rows of each kind. +CREATE TABLE IF NOT EXISTS scrobble_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service TEXT NOT NULL, + artist TEXT NOT NULL, + title TEXT NOT NULL, + album TEXT NOT NULL DEFAULT '', + duration_sec INTEGER NOT NULL DEFAULT 0, + timestamp_sec INTEGER NOT NULL, + from_youtube INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_scrobble_queue_service ON scrobble_queue (service, id); + +-- Likes and unlikes to mirror as loves; Last.fm and Libre.fm only. +CREATE TABLE IF NOT EXISTS scrobble_loves ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service TEXT NOT NULL, + artist TEXT NOT NULL, + title TEXT NOT NULL, + loved INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_scrobble_loves_service ON scrobble_loves (service, id); diff --git a/src-tauri/src/errors/mod.rs b/src-tauri/src/errors/mod.rs index 2178aabc..f4478b9a 100644 --- a/src-tauri/src/errors/mod.rs +++ b/src-tauri/src/errors/mod.rs @@ -57,6 +57,18 @@ pub enum AppError { #[error("Database error: {0}")] Database(#[from] sqlx::Error), + /// A scrobbling service refused the user name, password or token; the text is its own. + #[error("{0}")] + ScrobbleSignInRejected(String), + + /// Last.fm sign-in needs an API key this build does not have. + #[error("{0}")] + ScrobbleKeyMissing(String), + + /// The system keychain that holds scrobbling sessions cannot be used. + #[error("Keychain error: {0}")] + Keychain(String), + #[error("Streaming error: {0}")] #[allow(dead_code)] Streaming(String), @@ -140,6 +152,9 @@ impl From for ErrorResponse { AppError::Network(_) => "network", AppError::RateLimited(_) => "rateLimited", AppError::Database(_) => "database", + AppError::ScrobbleSignInRejected(_) => "scrobbleSignInRejected", + AppError::ScrobbleKeyMissing(_) => "scrobbleKeyMissing", + AppError::Keychain(_) => "keychain", AppError::Streaming(_) => "streaming", AppError::MediaDecode(_) => "mediaDecode", AppError::StreamExpired(_) => "streamExpired", @@ -163,7 +178,7 @@ impl From for ErrorResponse { tracing::error!(kind, error = %message, cause = %cause, "backend_error") } "extractor" | "streaming" | "validation" | "botCheckRequired" | "networkWall" - | "network" | "rateLimited" | "mediaDecode" | "streamExpired" => { + | "network" | "rateLimited" | "mediaDecode" | "streamExpired" | "keychain" => { tracing::warn!(kind, error = %message, cause = %cause, "backend_error") } _ => tracing::debug!(kind, error = %message, "backend_error"), @@ -196,6 +211,15 @@ mod tests { (AppError::MediaDecode("d".into()), "mediaDecode"), (AppError::StreamExpired("x".into()), "streamExpired"), (AppError::Database(sqlx::Error::RowNotFound), "database"), + ( + AppError::ScrobbleSignInRejected("s".into()), + "scrobbleSignInRejected", + ), + ( + AppError::ScrobbleKeyMissing("k".into()), + "scrobbleKeyMissing", + ), + (AppError::Keychain("c".into()), "keychain"), (AppError::Internal("i".into()), "internal"), ]; for (error, expected_kind) in cases { diff --git a/src-tauri/src/scrobble/audioscrobbler.rs b/src-tauri/src/scrobble/audioscrobbler.rs new file mode 100644 index 00000000..aa66d83b --- /dev/null +++ b/src-tauri/src/scrobble/audioscrobbler.rs @@ -0,0 +1,364 @@ +//! The Audioscrobbler 2.0 API that Last.fm and Libre.fm both speak: a mobile session from a user +//! name and password, now playing, scrobbles in batches of up to [`MAX_BATCH`], and loves. + +use std::collections::BTreeMap; +use std::fmt::Write; + +use md5::{Digest, Md5}; +use serde_json::Value; + +use super::SignInError; +use super::models::{Account, LoveEntry, ScrobbleEntry, SendOutcome}; +use crate::api::http::shared_client; + +pub const MAX_BATCH: usize = 50; +pub const LASTFM_URL: &str = "https://ws.audioscrobbler.com/2.0/"; +pub const LIBREFM_URL: &str = "https://libre.fm/2.0/"; + +/// Libre.fm registers no apps and takes any key; these only have to be 32 characters. +const LIBREFM_PLACEHOLDER: &str = "f10wf10wf10wf10wf10wf10wf10wf10w"; + +/// An app's identity on a Last.fm-compatible service. +#[derive(Clone, PartialEq, Eq)] +pub struct Keys { + pub api_key: String, + pub secret: String, +} + +impl Keys { + pub fn is_usable(&self) -> bool { + !self.api_key.trim().is_empty() && !self.secret.trim().is_empty() + } + + pub fn libre_fm() -> Self { + Self { + api_key: LIBREFM_PLACEHOLDER.to_string(), + secret: LIBREFM_PLACEHOLDER.to_string(), + } + } + + /// The key this build was made with, from the `LASTFM_API_KEY` and `LASTFM_API_SECRET` + /// build secrets; empty in builds without them, which leaves Last.fm sign-in off. + pub fn bundled_last_fm() -> Self { + Self { + api_key: option_env!("LASTFM_API_KEY") + .unwrap_or_default() + .to_string(), + secret: option_env!("LASTFM_API_SECRET") + .unwrap_or_default() + .to_string(), + } + } +} + +pub type Params = BTreeMap; + +/// The documented `api_sig`: every parameter but format and callback, sorted, joined, then the +/// secret, MD5'd. +pub fn signature(params: &Params, secret: &str) -> String { + let mut hasher = Md5::new(); + for (name, value) in params { + if name != "format" && name != "callback" { + hasher.update(name.as_bytes()); + hasher.update(value.as_bytes()); + } + } + hasher.update(secret.as_bytes()); + hasher + .finalize() + .iter() + .fold(String::with_capacity(32), |mut hex, byte| { + let _ = write!(hex, "{byte:02x}"); + hex + }) +} + +/// Session and key errors sign the account out; outages and rate limits retry; anything else +/// drops the batch, because sending it again would fail the same way. +pub fn outcome_of(error_code: Option) -> SendOutcome { + match error_code { + Some(4 | 9 | 10 | 26) => SendOutcome::SignedOut, + Some(8 | 11 | 16 | 29) => SendOutcome::Retry, + _ => SendOutcome::Sent, + } +} + +/// The form a call posts: its parameters plus the api key, the signature and the JSON format. +pub fn signed_form(mut params: Params, keys: &Keys) -> Vec<(String, String)> { + params.insert("api_key".into(), keys.api_key.clone()); + let sig = signature(¶ms, &keys.secret); + let mut form: Vec<(String, String)> = params.into_iter().collect(); + form.push(("api_sig".into(), sig)); + form.push(("format".into(), "json".into())); + form +} + +pub fn sign_in_params(user_name: &str, password: &str) -> Params { + Params::from([ + ("method".into(), "auth.getMobileSession".into()), + ("username".into(), user_name.into()), + ("password".into(), password.into()), + ]) +} + +pub fn now_playing_params(entry: &ScrobbleEntry, session_key: &str) -> Params { + let mut params = Params::from([ + ("method".into(), "track.updateNowPlaying".into()), + ("artist".into(), entry.artist.clone()), + ("track".into(), entry.title.clone()), + ("sk".into(), session_key.into()), + ]); + if !entry.album.trim().is_empty() { + params.insert("album".into(), entry.album.clone()); + } + if entry.duration_sec > 0 { + params.insert("duration".into(), entry.duration_sec.to_string()); + } + params +} + +pub fn scrobble_params(entries: &[ScrobbleEntry], session_key: &str) -> Params { + let mut params = Params::from([ + ("method".into(), "track.scrobble".into()), + ("sk".into(), session_key.into()), + ]); + for (index, entry) in entries.iter().take(MAX_BATCH).enumerate() { + params.insert(format!("artist[{index}]"), entry.artist.clone()); + params.insert(format!("track[{index}]"), entry.title.clone()); + params.insert( + format!("timestamp[{index}]"), + entry.timestamp_sec.to_string(), + ); + if !entry.album.trim().is_empty() { + params.insert(format!("album[{index}]"), entry.album.clone()); + } + if entry.duration_sec > 0 { + params.insert(format!("duration[{index}]"), entry.duration_sec.to_string()); + } + } + params +} + +pub fn love_params(love: &LoveEntry, session_key: &str) -> Params { + let method = if love.loved { + "track.love" + } else { + "track.unlove" + }; + Params::from([ + ("method".into(), method.into()), + ("artist".into(), love.artist.clone()), + ("track".into(), love.title.clone()), + ("sk".into(), session_key.into()), + ]) +} + +fn error_code(body: &Value) -> Option { + body.get("error").and_then(Value::as_i64) +} + +/// The account a successful `auth.getMobileSession` reply describes. +pub fn account_from_session(body: &Value, user_name: &str) -> Result { + if error_code(body).is_some() { + let message = body + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + return Err(SignInError::Rejected(message.to_string())); + } + let session = body + .get("session") + .ok_or_else(|| SignInError::Rejected(String::new()))?; + let key = session + .get("key") + .and_then(Value::as_str) + .filter(|key| !key.is_empty()) + .ok_or_else(|| SignInError::Rejected(String::new()))?; + Ok(Account { + user_name: session + .get("name") + .and_then(Value::as_str) + .unwrap_or(user_name) + .to_string(), + secret: key.to_string(), + }) +} + +/// One Last.fm-compatible service. +pub struct Client { + base_url: &'static str, +} + +impl Client { + pub const fn new(base_url: &'static str) -> Self { + Self { base_url } + } + + pub async fn sign_in( + &self, + user_name: &str, + password: &str, + keys: &Keys, + ) -> Result { + let body = self + .post(sign_in_params(user_name, password), keys) + .await + .map_err(SignInError::Network)?; + account_from_session(&body, user_name) + } + + pub async fn send(&self, params: Params, keys: &Keys) -> SendOutcome { + let method = params.get("method").cloned().unwrap_or_default(); + match self.post(params, keys).await { + Ok(body) => outcome_of(error_code(&body)), + Err(error) => { + tracing::warn!(method, %error, "Audioscrobbler call failed"); + SendOutcome::Retry + } + } + } + + async fn post(&self, params: Params, keys: &Keys) -> Result { + let response = shared_client() + .post(self.base_url) + .form(&signed_form(params, keys)) + .send() + .await + .map_err(|error| error.without_url().to_string())?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| error.without_url().to_string())?; + serde_json::from_str(&text).map_err(|_| format!("HTTP {status}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn params(pairs: &[(&str, &str)]) -> Params { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + #[test] + fn the_signature_follows_the_documented_recipe() { + let p = params(&[ + ("username", "u"), + ("method", "auth.getMobileSession"), + ("password", "p"), + ("api_key", "xxx"), + ("format", "json"), + ]); + assert_eq!(signature(&p, "s"), "f02a24fa543d12eeed6c58ed55b6c83a"); + } + + #[test] + fn the_form_is_signed_over_everything_but_format() { + let keys = Keys { + api_key: "key".into(), + secret: "secret".into(), + }; + let form = signed_form(sign_in_params("listener", "pw"), &keys); + let get = |name: &str| form.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone()); + assert_eq!(get("method").as_deref(), Some("auth.getMobileSession")); + assert_eq!(get("api_key").as_deref(), Some("key")); + assert_eq!(get("format").as_deref(), Some("json")); + let unsigned: Params = form + .iter() + .filter(|(k, _)| k != "api_sig" && k != "format") + .cloned() + .collect(); + assert_eq!(get("api_sig"), Some(signature(&unsigned, "secret"))); + } + + #[test] + fn a_session_reply_becomes_an_account_and_a_rejection_keeps_the_message() { + let ok = json!({"session": {"name": "Listener", "key": "SK", "subscriber": 0}}); + assert_eq!( + account_from_session(&ok, "listener").unwrap(), + Account { + user_name: "Listener".into(), + secret: "SK".into() + } + ); + let rejected = json!({"error": 4, "message": "Invalid username or password"}); + match account_from_session(&rejected, "u") { + Err(SignInError::Rejected(message)) => { + assert_eq!(message, "Invalid username or password"); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn a_batch_sends_indexed_fields() { + let entries = [ + ScrobbleEntry { + artist: "A".into(), + title: "One".into(), + album: String::new(), + duration_sec: 0, + timestamp_sec: 10, + from_youtube: true, + }, + ScrobbleEntry { + artist: "B".into(), + title: "Two".into(), + album: "Record".into(), + duration_sec: 180, + timestamp_sec: 20, + from_youtube: true, + }, + ]; + let p = scrobble_params(&entries, "SK"); + assert_eq!(p["track[1]"], "Two"); + assert_eq!(p["album[1]"], "Record"); + assert!(!p.contains_key("album[0]")); + assert!(!p.contains_key("duration[0]")); + assert_eq!(p["timestamp[0]"], "10"); + assert_eq!(p["sk"], "SK"); + } + + #[test] + fn session_errors_sign_out_outages_retry_and_others_drop_the_batch() { + assert_eq!(outcome_of(None), SendOutcome::Sent); + for code in [4, 9, 10, 26] { + assert_eq!(outcome_of(Some(code)), SendOutcome::SignedOut, "{code}"); + } + for code in [8, 11, 16, 29] { + assert_eq!(outcome_of(Some(code)), SendOutcome::Retry, "{code}"); + } + assert_eq!(outcome_of(Some(6)), SendOutcome::Sent); + } + + #[test] + fn a_like_becomes_track_love_and_an_unlike_track_unlove() { + let mut love = LoveEntry { + artist: "A".into(), + title: "Song".into(), + loved: true, + }; + assert_eq!(love_params(&love, "SK")["method"], "track.love"); + love.loved = false; + assert_eq!(love_params(&love, "SK")["method"], "track.unlove"); + } + + #[test] + fn libre_fm_uses_a_32_character_placeholder_and_an_empty_build_key_is_unusable() { + assert_eq!(Keys::libre_fm().api_key.len(), 32); + assert!(Keys::libre_fm().is_usable()); + assert!( + !Keys { + api_key: String::new(), + secret: "s".into() + } + .is_usable() + ); + } +} diff --git a/src-tauri/src/scrobble/credentials.rs b/src-tauri/src/scrobble/credentials.rs new file mode 100644 index 00000000..c1258352 --- /dev/null +++ b/src-tauri/src/scrobble/credentials.rs @@ -0,0 +1,189 @@ +//! Session keys, tokens and the listener's own Last.fm key live in the OS keychain, never in the +//! settings table, so sync, backups and settings exports can never carry them. + +use serde::{Deserialize, Serialize}; + +use super::models::{Account, Service}; + +const KEYCHAIN_SERVICE: &str = "io.github.aedev.flow.desktop.scrobbling"; +const OWN_KEY_ENTRY: &str = "LASTFM_OWN_KEY"; + +/// Why the keychain could not be used. Carries no secret material. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct CredentialError(pub &'static str); + +/// The listener's own Last.fm API key, used instead of the build's when enabled. +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OwnKey { + pub enabled: bool, + pub api_key: String, + pub secret: String, +} + +impl std::fmt::Debug for OwnKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OwnKey") + .field("enabled", &self.enabled) + .field("api_key", &self.api_key) + .field("secret", &"") + .finish() + } +} + +pub trait CredentialStore: Send + Sync { + fn read(&self, name: &str) -> Result, CredentialError>; + fn write(&self, name: &str, value: &str) -> Result<(), CredentialError>; + fn remove(&self, name: &str) -> Result<(), CredentialError>; +} + +pub fn read_account( + store: &dyn CredentialStore, + service: Service, +) -> Result, CredentialError> { + Ok(store + .read(service.as_str())? + .and_then(|value| serde_json::from_str::(&value).ok()) + .filter(|account| !account.secret.is_empty())) +} + +pub fn write_account( + store: &dyn CredentialStore, + service: Service, + account: Option<&Account>, +) -> Result<(), CredentialError> { + match account { + Some(account) => { + let value = serde_json::to_string(account) + .map_err(|_| CredentialError("Could not encode the account"))?; + store.write(service.as_str(), &value) + } + None => store.remove(service.as_str()), + } +} + +pub fn read_own_key(store: &dyn CredentialStore) -> Result { + Ok(store + .read(OWN_KEY_ENTRY)? + .and_then(|value| serde_json::from_str(&value).ok()) + .unwrap_or_default()) +} + +pub fn write_own_key(store: &dyn CredentialStore, key: &OwnKey) -> Result<(), CredentialError> { + let value = + serde_json::to_string(key).map_err(|_| CredentialError("Could not encode the key"))?; + store.write(OWN_KEY_ENTRY, &value) +} + +/// The platform keychain through the `keyring` crate. +pub struct Keychain; + +impl Keychain { + fn entry(name: &str) -> Result { + keyring::Entry::new(KEYCHAIN_SERVICE, name).map_err(|error| describe(&error)) + } +} + +impl CredentialStore for Keychain { + fn read(&self, name: &str) -> Result, CredentialError> { + match Self::entry(name)?.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(describe(&error)), + } + } + + fn write(&self, name: &str, value: &str) -> Result<(), CredentialError> { + Self::entry(name)? + .set_password(value) + .map_err(|error| describe(&error)) + } + + fn remove(&self, name: &str) -> Result<(), CredentialError> { + match Self::entry(name)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(describe(&error)), + } + } +} + +/// A fixed description per failure: the keyring errors can carry the stored bytes. +fn describe(error: &keyring::Error) -> CredentialError { + CredentialError(match error { + keyring::Error::NoStorageAccess(_) => "The system keychain is locked or refused access", + keyring::Error::NoDefaultStore | keyring::Error::NotSupportedByStore(_) => { + "No system keychain is available" + } + _ => "The system keychain failed", + }) +} + +#[cfg(test)] +pub mod memory { + use std::collections::HashMap; + use std::sync::Mutex; + + use super::{CredentialError, CredentialStore}; + + /// An in-memory stand-in for the keychain in tests. + #[derive(Default)] + pub struct MemoryStore(pub Mutex>); + + impl CredentialStore for MemoryStore { + fn read(&self, name: &str) -> Result, CredentialError> { + Ok(self.0.lock().unwrap().get(name).cloned()) + } + + fn write(&self, name: &str, value: &str) -> Result<(), CredentialError> { + self.0 + .lock() + .unwrap() + .insert(name.to_string(), value.to_string()); + Ok(()) + } + + fn remove(&self, name: &str) -> Result<(), CredentialError> { + self.0.lock().unwrap().remove(name); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::memory::MemoryStore; + use super::*; + + #[test] + fn accounts_round_trip_and_sign_out_removes_them() { + let store = MemoryStore::default(); + let account = Account { + user_name: "listener".into(), + secret: "SK".into(), + }; + write_account(&store, Service::LastFm, Some(&account)).unwrap(); + assert_eq!( + read_account(&store, Service::LastFm).unwrap(), + Some(account) + ); + assert_eq!(read_account(&store, Service::LibreFm).unwrap(), None); + write_account(&store, Service::LastFm, None).unwrap(); + assert_eq!(read_account(&store, Service::LastFm).unwrap(), None); + } + + #[test] + fn an_account_never_prints_its_secret() { + let account = Account { + user_name: "listener".into(), + secret: "SESSION-KEY".into(), + }; + assert!(!format!("{account:?}").contains("SESSION-KEY")); + } + + #[test] + fn a_missing_own_key_reads_as_disabled() { + let store = MemoryStore::default(); + assert_eq!(read_own_key(&store).unwrap(), OwnKey::default()); + } +} diff --git a/src-tauri/src/scrobble/listenbrainz.rs b/src-tauri/src/scrobble/listenbrainz.rs new file mode 100644 index 00000000..02a66fd1 --- /dev/null +++ b/src-tauri/src/scrobble/listenbrainz.rs @@ -0,0 +1,215 @@ +//! The `ListenBrainz` submit API, signed in with the user token from the listener's settings page. + +use reqwest::StatusCode; +use serde_json::{Map, Value, json}; + +use super::SignInError; +use super::models::{Account, ScrobbleEntry, SendOutcome}; +use crate::api::http::shared_client; + +pub const MAX_BATCH: usize = 100; +const BASE_URL: &str = "https://api.listenbrainz.org/1"; +const PLAYING_NOW: &str = "playing_now"; +const SINGLE: &str = "single"; +const IMPORT: &str = "import"; + +/// The submit-listens body; a now-playing listen carries no timestamp. +pub fn listens_json(listen_type: &str, entries: &[ScrobbleEntry]) -> Value { + let payload: Vec = entries + .iter() + .map(|entry| { + let mut info = Map::new(); + if entry.duration_sec > 0 { + info.insert( + "duration_ms".into(), + json!(u64::from(entry.duration_sec) * 1000), + ); + } + info.insert("submission_client".into(), json!("Flow Desktop")); + info.insert( + "submission_client_version".into(), + json!(env!("CARGO_PKG_VERSION")), + ); + if entry.from_youtube { + info.insert("music_service".into(), json!("music.youtube.com")); + } + let mut metadata = Map::new(); + metadata.insert("artist_name".into(), json!(entry.artist)); + metadata.insert("track_name".into(), json!(entry.title)); + if !entry.album.trim().is_empty() { + metadata.insert("release_name".into(), json!(entry.album)); + } + metadata.insert("additional_info".into(), Value::Object(info)); + let mut listen = Map::new(); + if listen_type != PLAYING_NOW { + listen.insert("listened_at".into(), json!(entry.timestamp_sec)); + } + listen.insert("track_metadata".into(), Value::Object(metadata)); + Value::Object(listen) + }) + .collect(); + json!({ "listen_type": listen_type, "payload": payload }) +} + +/// A bad token signs out; rate limits and outages retry; any other refusal drops the batch. +pub fn outcome_for_status(status: StatusCode) -> SendOutcome { + if status.is_success() { + SendOutcome::Sent + } else if status == StatusCode::UNAUTHORIZED { + SendOutcome::SignedOut + } else if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() { + SendOutcome::Retry + } else { + SendOutcome::Sent + } +} + +/// The account a `validate-token` reply describes. +pub fn account_from_validation(body: &Value, token: &str) -> Result { + if body.get("valid").and_then(Value::as_bool) != Some(true) { + let message = body + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + return Err(SignInError::Rejected(message.to_string())); + } + Ok(Account { + user_name: body + .get("user_name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + secret: token.to_string(), + }) +} + +fn authorization(token: &str) -> String { + format!("Token {token}") +} + +pub async fn sign_in(token: &str) -> Result { + let response = shared_client() + .get(format!("{BASE_URL}/validate-token")) + .header(reqwest::header::AUTHORIZATION, authorization(token)) + .send() + .await + .map_err(|error| SignInError::Network(error.without_url().to_string()))?; + let status = response.status(); + if status.is_server_error() { + return Err(SignInError::Network(format!("HTTP {status}"))); + } + let body: Value = response + .json() + .await + .map_err(|_| SignInError::Network(format!("HTTP {status}")))?; + account_from_validation(&body, token) +} + +pub async fn now_playing(entry: &ScrobbleEntry, account: &Account) -> SendOutcome { + submit(PLAYING_NOW, std::slice::from_ref(entry), account).await +} + +pub async fn scrobble(entries: &[ScrobbleEntry], account: &Account) -> SendOutcome { + let batch = &entries[..entries.len().min(MAX_BATCH)]; + let listen_type = if batch.len() == 1 { SINGLE } else { IMPORT }; + submit(listen_type, batch, account).await +} + +async fn submit(listen_type: &str, entries: &[ScrobbleEntry], account: &Account) -> SendOutcome { + let result = shared_client() + .post(format!("{BASE_URL}/submit-listens")) + .header( + reqwest::header::AUTHORIZATION, + authorization(&account.secret), + ) + .json(&listens_json(listen_type, entries)) + .send() + .await; + match result { + Ok(response) => outcome_for_status(response.status()), + Err(error) => { + tracing::warn!(error = %error.without_url(), "ListenBrainz submit failed"); + SendOutcome::Retry + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(from_youtube: bool) -> ScrobbleEntry { + ScrobbleEntry { + artist: "Artist".into(), + title: "Song".into(), + album: "Album".into(), + duration_sec: 200, + timestamp_sec: 1_700_000_000, + from_youtube, + } + } + + #[test] + fn now_playing_carries_no_timestamp() { + let body = listens_json(PLAYING_NOW, &[entry(true)]); + let listen = &body["payload"][0]; + assert!(listen.get("listened_at").is_none()); + assert_eq!(listen["track_metadata"]["artist_name"], "Artist"); + assert_eq!(body["listen_type"], "playing_now"); + } + + #[test] + fn a_finished_listen_has_its_time_and_its_source() { + let body = listens_json(SINGLE, &[entry(true)]); + let listen = &body["payload"][0]; + let info = &listen["track_metadata"]["additional_info"]; + assert_eq!(listen["listened_at"], 1_700_000_000); + assert_eq!(info["duration_ms"], 200_000); + assert_eq!(info["music_service"], "music.youtube.com"); + assert_eq!(listen["track_metadata"]["release_name"], "Album"); + let local = listens_json(SINGLE, &[entry(false)]); + assert!( + local["payload"][0]["track_metadata"]["additional_info"] + .get("music_service") + .is_none() + ); + } + + #[test] + fn statuses_map_to_outcomes() { + assert_eq!(outcome_for_status(StatusCode::OK), SendOutcome::Sent); + assert_eq!( + outcome_for_status(StatusCode::UNAUTHORIZED), + SendOutcome::SignedOut + ); + assert_eq!( + outcome_for_status(StatusCode::TOO_MANY_REQUESTS), + SendOutcome::Retry + ); + assert_eq!( + outcome_for_status(StatusCode::BAD_GATEWAY), + SendOutcome::Retry + ); + assert_eq!( + outcome_for_status(StatusCode::BAD_REQUEST), + SendOutcome::Sent + ); + } + + #[test] + fn a_token_is_valid_only_when_the_service_says_so() { + let ok = json!({"valid": true, "user_name": "listener", "code": 200}); + assert_eq!( + account_from_validation(&ok, "tok").unwrap(), + Account { + user_name: "listener".into(), + secret: "tok".into() + } + ); + let bad = json!({"valid": false, "message": "Invalid authorization token."}); + assert!(matches!( + account_from_validation(&bad, "tok"), + Err(SignInError::Rejected(message)) if message == "Invalid authorization token." + )); + } +} diff --git a/src-tauri/src/scrobble/mod.rs b/src-tauri/src/scrobble/mod.rs new file mode 100644 index 00000000..804159b0 --- /dev/null +++ b/src-tauri/src/scrobble/mod.rs @@ -0,0 +1,21 @@ +//! Scrobbling to Last.fm, Libre.fm and `ListenBrainz`, ported from Flow for Android's +//! `data/scrobble`. The music player reports listens; counted listens wait in SQLite and a +//! background worker sends them, so offline listening loses nothing. + +pub mod audioscrobbler; +pub mod credentials; +pub mod listenbrainz; +pub mod models; +pub mod queue; +pub mod rules; +pub mod scrobbler; +pub mod worker; + +/// Why a sign-in did not produce an account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignInError { + /// The service refused the credentials; the text is the service's own message, if any. + Rejected(String), + /// The service could not be reached. + Network(String), +} diff --git a/src-tauri/src/scrobble/models.rs b/src-tauri/src/scrobble/models.rs new file mode 100644 index 00000000..6f5fdbcb --- /dev/null +++ b/src-tauri/src/scrobble/models.rs @@ -0,0 +1,77 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// The services Flow can scrobble to. The names are stored, so never rename them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Service { + #[serde(rename = "LASTFM")] + LastFm, + #[serde(rename = "LIBREFM")] + LibreFm, + #[serde(rename = "LISTENBRAINZ")] + ListenBrainz, +} + +impl Service { + pub const ALL: [Service; 3] = [Service::LastFm, Service::LibreFm, Service::ListenBrainz]; + + pub fn as_str(self) -> &'static str { + match self { + Service::LastFm => "LASTFM", + Service::LibreFm => "LIBREFM", + Service::ListenBrainz => "LISTENBRAINZ", + } + } + + /// Loves need `MusicBrainz` ids on `ListenBrainz`, so only the Audioscrobbler services take them. + pub fn takes_loves(self) -> bool { + self != Service::ListenBrainz + } +} + +/// One counted listen, as every service needs it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScrobbleEntry { + pub artist: String, + pub title: String, + pub album: String, + pub duration_sec: u32, + pub timestamp_sec: i64, + pub from_youtube: bool, +} + +/// A like or unlike to mirror as a love on Last.fm and Libre.fm. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoveEntry { + pub artist: String, + pub title: String, + pub loved: bool, +} + +/// A signed-in account: the name to show and the session key or token the service gave. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub user_name: String, + pub secret: String, +} + +impl fmt::Debug for Account { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Account") + .field("user_name", &self.user_name) + .field("secret", &"") + .finish() + } +} + +/// What one attempt to send left behind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SendOutcome { + Sent, + /// Network trouble or the service is down; keep the entries and try later. + Retry, + /// The service no longer accepts the account; the listener has to sign in again. + SignedOut, +} diff --git a/src-tauri/src/scrobble/queue.rs b/src-tauri/src/scrobble/queue.rs new file mode 100644 index 00000000..6211daa7 --- /dev/null +++ b/src-tauri/src/scrobble/queue.rs @@ -0,0 +1,258 @@ +//! The offline queues: listens and loves waiting for each service, oldest first, capped at +//! [`MAX_QUEUE`] per service so a long offline stretch keeps the newest. + +use sqlx::{Row, SqlitePool}; + +use super::models::{LoveEntry, ScrobbleEntry, Service}; +use crate::errors::AppResult; + +pub const MAX_QUEUE: i64 = 2_000; + +/// A queued row with the id it is dropped by once sent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Queued { + pub id: i64, + pub item: T, +} + +pub async fn enqueue(pool: &SqlitePool, service: Service, entry: &ScrobbleEntry) -> AppResult<()> { + let mut tx = pool.begin().await?; + sqlx::query( + "INSERT INTO scrobble_queue (service, artist, title, album, duration_sec, timestamp_sec, from_youtube) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(service.as_str()) + .bind(&entry.artist) + .bind(&entry.title) + .bind(&entry.album) + .bind(i64::from(entry.duration_sec)) + .bind(entry.timestamp_sec) + .bind(entry.from_youtube) + .execute(&mut *tx) + .await?; + sqlx::query( + "DELETE FROM scrobble_queue WHERE service = ?1 AND id NOT IN + (SELECT id FROM scrobble_queue WHERE service = ?1 ORDER BY id DESC LIMIT ?2)", + ) + .bind(service.as_str()) + .bind(MAX_QUEUE) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +pub async fn pending( + pool: &SqlitePool, + service: Service, + limit: usize, +) -> AppResult>> { + let rows = sqlx::query( + "SELECT id, artist, title, album, duration_sec, timestamp_sec, from_youtube + FROM scrobble_queue WHERE service = ? ORDER BY id LIMIT ?", + ) + .bind(service.as_str()) + .bind(i64::try_from(limit).unwrap_or(i64::MAX)) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|row| Queued { + id: row.get("id"), + item: ScrobbleEntry { + artist: row.get("artist"), + title: row.get("title"), + album: row.get("album"), + duration_sec: u32::try_from(row.get::("duration_sec")).unwrap_or(0), + timestamp_sec: row.get("timestamp_sec"), + from_youtube: row.get("from_youtube"), + }, + }) + .collect()) +} + +pub async fn drop_sent(pool: &SqlitePool, table: Table, sent: &[Queued]) -> AppResult<()> { + let mut tx = pool.begin().await?; + for queued in sent { + sqlx::query(table.delete_sql()) + .bind(queued.id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +#[derive(Debug, Clone, Copy)] +pub enum Table { + Listens, + Loves, +} + +impl Table { + fn delete_sql(self) -> &'static str { + match self { + Table::Listens => "DELETE FROM scrobble_queue WHERE id = ?", + Table::Loves => "DELETE FROM scrobble_loves WHERE id = ?", + } + } +} + +pub async fn enqueue_love(pool: &SqlitePool, service: Service, love: &LoveEntry) -> AppResult<()> { + let mut tx = pool.begin().await?; + sqlx::query("INSERT INTO scrobble_loves (service, artist, title, loved) VALUES (?, ?, ?, ?)") + .bind(service.as_str()) + .bind(&love.artist) + .bind(&love.title) + .bind(love.loved) + .execute(&mut *tx) + .await?; + sqlx::query( + "DELETE FROM scrobble_loves WHERE service = ?1 AND id NOT IN + (SELECT id FROM scrobble_loves WHERE service = ?1 ORDER BY id DESC LIMIT ?2)", + ) + .bind(service.as_str()) + .bind(MAX_QUEUE) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +pub async fn pending_loves( + pool: &SqlitePool, + service: Service, +) -> AppResult>> { + let rows = sqlx::query( + "SELECT id, artist, title, loved FROM scrobble_loves WHERE service = ? ORDER BY id", + ) + .bind(service.as_str()) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|row| Queued { + id: row.get("id"), + item: LoveEntry { + artist: row.get("artist"), + title: row.get("title"), + loved: row.get("loved"), + }, + }) + .collect()) +} + +/// How many listens and loves are still waiting for this service. +pub async fn waiting(pool: &SqlitePool, service: Service) -> AppResult { + let count: i64 = sqlx::query_scalar( + "SELECT (SELECT COUNT(*) FROM scrobble_queue WHERE service = ?1) + + (SELECT COUNT(*) FROM scrobble_loves WHERE service = ?1)", + ) + .bind(service.as_str()) + .fetch_one(pool) + .await?; + Ok(count) +} + +/// Signing out drops what was waiting for that account. +pub async fn clear(pool: &SqlitePool, service: Service) -> AppResult<()> { + let mut tx = pool.begin().await?; + for sql in [ + "DELETE FROM scrobble_queue WHERE service = ?", + "DELETE FROM scrobble_loves WHERE service = ?", + ] { + sqlx::query(sql) + .bind(service.as_str()) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + async fn pool() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + pool + } + + fn entry(n: i64) -> ScrobbleEntry { + ScrobbleEntry { + artist: "Artist".into(), + title: format!("Song {n}"), + album: String::new(), + duration_sec: 200, + timestamp_sec: n, + from_youtube: true, + } + } + + #[tokio::test] + async fn listens_come_back_oldest_first_per_service_and_leave_once_sent() { + let pool = pool().await; + for n in 1..=3 { + enqueue(&pool, Service::LastFm, &entry(n)).await.unwrap(); + } + enqueue(&pool, Service::ListenBrainz, &entry(9)) + .await + .unwrap(); + + let batch = pending(&pool, Service::LastFm, 2).await.unwrap(); + assert_eq!( + batch + .iter() + .map(|q| q.item.timestamp_sec) + .collect::>(), + [1, 2] + ); + drop_sent(&pool, Table::Listens, &batch).await.unwrap(); + + let rest = pending(&pool, Service::LastFm, 50).await.unwrap(); + assert_eq!(rest.len(), 1); + assert_eq!(rest[0].item, entry(3)); + assert_eq!(waiting(&pool, Service::ListenBrainz).await.unwrap(), 1); + } + + #[tokio::test] + async fn each_service_keeps_only_the_newest_two_thousand() { + let pool = pool().await; + for n in 0..(MAX_QUEUE + 5) { + enqueue(&pool, Service::LibreFm, &entry(n)).await.unwrap(); + } + enqueue(&pool, Service::LastFm, &entry(1)).await.unwrap(); + + assert_eq!(waiting(&pool, Service::LibreFm).await.unwrap(), MAX_QUEUE); + let oldest = pending(&pool, Service::LibreFm, 1).await.unwrap(); + assert_eq!(oldest[0].item.timestamp_sec, 5); + assert_eq!(waiting(&pool, Service::LastFm).await.unwrap(), 1); + } + + #[tokio::test] + async fn loves_queue_with_listens_and_clearing_drops_both() { + let pool = pool().await; + let love = LoveEntry { + artist: "A".into(), + title: "Song".into(), + loved: true, + }; + enqueue_love(&pool, Service::LastFm, &love).await.unwrap(); + enqueue(&pool, Service::LastFm, &entry(1)).await.unwrap(); + assert_eq!(waiting(&pool, Service::LastFm).await.unwrap(), 2); + assert_eq!( + pending_loves(&pool, Service::LastFm).await.unwrap()[0].item, + love + ); + + clear(&pool, Service::LastFm).await.unwrap(); + assert_eq!(waiting(&pool, Service::LastFm).await.unwrap(), 0); + } +} diff --git a/src-tauri/src/scrobble/rules.rs b/src-tauri/src/scrobble/rules.rs new file mode 100644 index 00000000..be0159b5 --- /dev/null +++ b/src-tauri/src/scrobble/rules.rs @@ -0,0 +1,143 @@ +//! The listen rules every scrobbling service shares: a track longer than 30 seconds counts once it +//! has played for half its length or four minutes, whichever comes first. + +use serde::Deserialize; + +use super::models::{LoveEntry, ScrobbleEntry}; + +const MIN_DURATION_MS: u64 = 30_000; +const MAX_THRESHOLD_MS: u64 = 4 * 60_000; +const TOPIC_SUFFIX: &str = " - Topic"; +const UNKNOWN_ALBUM: &str = "Unknown Album"; + +/// The song as the music player knows it. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrackInfo { + pub title: String, + #[serde(default)] + pub artists: Vec, + #[serde(default)] + pub album: Option, + pub duration_ms: u64, +} + +/// How long a track must play before it counts; `None` when it is too short to ever count. +pub fn threshold_ms(duration_ms: u64) -> Option { + (duration_ms > MIN_DURATION_MS).then(|| (duration_ms / 2).min(MAX_THRESHOLD_MS)) +} + +pub fn counts(duration_ms: u64, played_ms: u64) -> bool { + threshold_ms(duration_ms).is_some_and(|threshold| played_ms >= threshold) +} + +/// `None` when the track has no artist or title to send. +pub fn entry_for(track: &TrackInfo, started_at_ms: i64) -> Option { + let (artist, title) = artist_and_title(track)?; + let album = track + .album + .as_deref() + .map(str::trim) + .filter(|album| !album.eq_ignore_ascii_case(UNKNOWN_ALBUM)) + .unwrap_or_default() + .to_string(); + Some(ScrobbleEntry { + artist, + title, + album, + duration_sec: u32::try_from(track.duration_ms / 1000).unwrap_or(u32::MAX), + timestamp_sec: started_at_ms / 1000, + from_youtube: true, + }) +} + +pub fn love_for(track: &TrackInfo, loved: bool) -> Option { + let (artist, title) = artist_and_title(track)?; + Some(LoveEntry { + artist, + title, + loved, + }) +} + +/// The first credited artist: joining every name makes Last.fm invent a new artist. +fn artist_and_title(track: &TrackInfo) -> Option<(String, String)> { + let first = track + .artists + .first() + .map(String::as_str) + .unwrap_or_default(); + let artist = first.strip_suffix(TOPIC_SUFFIX).unwrap_or(first).trim(); + let title = track.title.trim(); + (!artist.is_empty() && !title.is_empty()).then(|| (artist.to_string(), title.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(artists: &[&str], album: Option<&str>) -> TrackInfo { + TrackInfo { + title: " Song ".into(), + artists: artists.iter().map(|a| (*a).to_string()).collect(), + album: album.map(str::to_string), + duration_ms: 200_000, + } + } + + #[test] + fn a_listen_counts_at_half_the_track_or_four_minutes() { + assert!(!counts(200_000, 99_000)); + assert!(counts(200_000, 100_000)); + assert!(counts(20 * 60_000, 4 * 60_000)); + assert!(!counts(30_000, 30_000)); + } + + #[test] + fn the_threshold_is_half_capped_at_four_minutes_and_absent_for_short_tracks() { + assert_eq!(threshold_ms(200_000), Some(100_000)); + assert_eq!(threshold_ms(20 * 60_000), Some(4 * 60_000)); + assert_eq!(threshold_ms(30_000), None); + assert_eq!(threshold_ms(30_001), Some(15_000)); + } + + #[test] + fn the_entry_takes_the_first_artist_and_drops_the_topic_suffix() { + let entry = entry_for(&track(&["Artist - Topic"], Some("Album")), 5_000_000).unwrap(); + assert_eq!(entry.artist, "Artist"); + let entry = entry_for(&track(&["Lead", "Guest"], None), 5_000_000).unwrap(); + assert_eq!(entry.artist, "Lead"); + assert_eq!(entry.title, "Song"); + assert_eq!(entry.timestamp_sec, 5_000); + assert_eq!(entry.duration_sec, 200); + } + + #[test] + fn placeholder_albums_are_left_out() { + let entry = entry_for(&track(&["A"], Some("Unknown Album")), 0).unwrap(); + assert!(entry.album.is_empty()); + let entry = entry_for(&track(&["A"], Some(" Record ")), 0).unwrap(); + assert_eq!(entry.album, "Record"); + } + + #[test] + fn nothing_is_sent_without_an_artist_or_title() { + assert!(entry_for(&track(&[" "], None), 0).is_none()); + assert!(entry_for(&track(&[], None), 0).is_none()); + let mut untitled = track(&["A"], None); + untitled.title = " ".into(); + assert!(entry_for(&untitled, 0).is_none()); + } + + #[test] + fn a_love_uses_the_same_artist_as_the_scrobble() { + assert_eq!( + love_for(&track(&["Artist - Topic"], None), true), + Some(LoveEntry { + artist: "Artist".into(), + title: "Song".into(), + loved: true + }) + ); + } +} diff --git a/src-tauri/src/scrobble/scrobbler.rs b/src-tauri/src/scrobble/scrobbler.rs new file mode 100644 index 00000000..7728ebbe --- /dev/null +++ b/src-tauri/src/scrobble/scrobbler.rs @@ -0,0 +1,583 @@ +//! Sends what the music player plays to every signed-in service. Counted listens are queued first +//! and sent by the worker, so a listen survives going offline or quitting. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, PoisonError}; + +use serde::Serialize; +use sqlx::SqlitePool; +use tokio::sync::Notify; + +use super::SignInError; +use super::audioscrobbler::{self, Client, Keys}; +use super::credentials::{self, CredentialError, CredentialStore, OwnKey}; +use super::listenbrainz; +use super::models::{Account, ScrobbleEntry, SendOutcome, Service}; +use super::queue::{self, Table}; +use super::rules::{self, TrackInfo}; +use crate::errors::{AppError, AppResult}; + +const LAST_FM: Client = Client::new(audioscrobbler::LASTFM_URL); +const LIBRE_FM: Client = Client::new(audioscrobbler::LIBREFM_URL); +const VISIBLE_KEY_CHARS: usize = 6; + +#[derive(Clone, Default)] +struct Credentials { + accounts: HashMap, + own_key: OwnKey, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServiceStatus { + pub service: Service, + pub signed_in: bool, + pub user_name: Option, + pub waiting: i64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleStatus { + pub services: Vec, + /// Last.fm sign-in has a key to sign with: the build's, or the listener's own. + pub last_fm_ready: bool, + pub own_key_enabled: bool, + /// The first characters of the listener's own API key, never the secret. + pub own_key_hint: Option, + /// Set when the system keychain cannot be used, so nothing can sign in. + pub keychain_error: Option, +} + +pub struct Scrobbler { + pool: SqlitePool, + store: Arc, + cache: Mutex>, + flush_lock: tokio::sync::Mutex<()>, + pub(super) wake: Notify, +} + +impl Scrobbler { + pub fn new(pool: SqlitePool, store: Arc) -> Self { + Self { + pool, + store, + cache: Mutex::new(None), + flush_lock: tokio::sync::Mutex::new(()), + wake: Notify::new(), + } + } + + /// The key Last.fm calls are signed with: the listener's own when they turned it on, else the + /// build's. `None` when neither is usable. + fn last_fm_keys(own_key: &OwnKey) -> Option { + let keys = if own_key.enabled { + Keys { + api_key: own_key.api_key.clone(), + secret: own_key.secret.clone(), + } + } else { + Keys::bundled_last_fm() + }; + keys.is_usable().then_some(keys) + } + + async fn blocking( + &self, + job: impl FnOnce(&dyn CredentialStore) -> Result + Send + 'static, + ) -> AppResult { + let store = Arc::clone(&self.store); + tokio::task::spawn_blocking(move || job(store.as_ref())) + .await + .map_err(|error| AppError::Internal(error.to_string()))? + .map_err(|error| AppError::Keychain(error.to_string())) + } + + async fn credentials(&self) -> AppResult { + if let Some(cached) = self.lock_cache().clone() { + return Ok(cached); + } + let loaded = self + .blocking(|store| { + let mut accounts = HashMap::new(); + for service in Service::ALL { + if let Some(account) = credentials::read_account(store, service)? { + accounts.insert(service, account); + } + } + Ok(Credentials { + accounts, + own_key: credentials::read_own_key(store)?, + }) + }) + .await?; + *self.lock_cache() = Some(loaded.clone()); + Ok(loaded) + } + + fn lock_cache(&self) -> std::sync::MutexGuard<'_, Option> { + self.cache.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub async fn status(&self) -> AppResult { + let (creds, keychain_error) = match self.credentials().await { + Ok(creds) => (creds, None), + Err(error) => (Credentials::default(), Some(error.to_string())), + }; + let mut services = Vec::new(); + for service in Service::ALL { + let account = creds.accounts.get(&service); + services.push(ServiceStatus { + service, + signed_in: account.is_some(), + user_name: account + .map(|a| a.user_name.clone()) + .filter(|name| !name.is_empty()), + waiting: queue::waiting(&self.pool, service).await?, + }); + } + let api_key = creds.own_key.api_key.trim(); + Ok(ScrobbleStatus { + services, + last_fm_ready: Self::last_fm_keys(&creds.own_key).is_some(), + own_key_enabled: creds.own_key.enabled, + own_key_hint: (!api_key.is_empty()) + .then(|| api_key.chars().take(VISIBLE_KEY_CHARS).collect()), + keychain_error, + }) + } + + /// Signs in with a user name and password (Last.fm, Libre.fm) or a user token (`ListenBrainz`). + /// The password is sent once and never kept. + pub async fn sign_in( + &self, + service: Service, + user_name: &str, + password_or_token: &str, + ) -> AppResult<()> { + let creds = self.credentials().await?; + let result = match service { + Service::LastFm => { + let keys = Self::last_fm_keys(&creds.own_key).ok_or_else(|| { + AppError::ScrobbleKeyMissing("This build has no Last.fm API key".into()) + })?; + LAST_FM + .sign_in(user_name.trim(), password_or_token, &keys) + .await + } + Service::LibreFm => { + LIBRE_FM + .sign_in(user_name.trim(), password_or_token, &Keys::libre_fm()) + .await + } + Service::ListenBrainz => listenbrainz::sign_in(password_or_token.trim()).await, + }; + let account = result.map_err(|error| match error { + SignInError::Rejected(message) => AppError::ScrobbleSignInRejected(message), + SignInError::Network(message) => AppError::Network(message), + })?; + self.set_account(service, Some(account)).await + } + + async fn set_account(&self, service: Service, account: Option) -> AppResult<()> { + let stored = account.clone(); + self.blocking(move |store| credentials::write_account(store, service, stored.as_ref())) + .await?; + if let Some(cache) = self.lock_cache().as_mut() { + match account { + Some(account) => { + cache.accounts.insert(service, account); + } + None => { + cache.accounts.remove(&service); + } + } + } + Ok(()) + } + + /// Signing out drops the listens still waiting for that account, as on Android. + pub async fn sign_out(&self, service: Service) -> AppResult<()> { + self.set_account(service, None).await?; + queue::clear(&self.pool, service).await + } + + /// A Last.fm session belongs to the key it was made with, so changing the key signs out. + /// A missing key or secret keeps the stored one, so the switch alone can be flipped. + pub async fn set_own_key( + &self, + enabled: bool, + api_key: Option, + secret: Option, + ) -> AppResult<()> { + let before = self.credentials().await?.own_key; + let next = OwnKey { + enabled, + api_key: api_key.map_or_else(|| before.api_key.clone(), |key| key.trim().to_string()), + secret: secret + .map_or_else(|| before.secret.clone(), |secret| secret.trim().to_string()), + }; + let stored = next.clone(); + self.blocking(move |store| credentials::write_own_key(store, &stored)) + .await?; + if let Some(cache) = self.lock_cache().as_mut() { + cache.own_key = next.clone(); + } + let changed = before.enabled != next.enabled + || (next.enabled && (before.api_key != next.api_key || before.secret != next.secret)); + if changed { + self.sign_out(Service::LastFm).await?; + } + Ok(()) + } + + /// Shows the song as now playing on every signed-in service. + pub async fn now_playing(&self, track: &TrackInfo) -> AppResult<()> { + let Some(entry) = rules::entry_for(track, now_ms()) else { + return Ok(()); + }; + let creds = self.credentials().await?; + for (service, account) in &creds.accounts { + let outcome = match service { + Service::LastFm => match Self::last_fm_keys(&creds.own_key) { + Some(keys) => { + LAST_FM + .send( + audioscrobbler::now_playing_params(&entry, &account.secret), + &keys, + ) + .await + } + None => continue, + }, + Service::LibreFm => { + LIBRE_FM + .send( + audioscrobbler::now_playing_params(&entry, &account.secret), + &Keys::libre_fm(), + ) + .await + } + Service::ListenBrainz => listenbrainz::now_playing(&entry, account).await, + }; + if outcome == SendOutcome::SignedOut { + tracing::warn!( + service = service.as_str(), + "Scrobbling service signed the account out" + ); + self.sign_out(*service).await?; + } + } + Ok(()) + } + + /// Queues a listen that counts for every signed-in service. True when it was queued. + pub async fn listened( + &self, + track: &TrackInfo, + played_ms: u64, + started_at_ms: i64, + ) -> AppResult { + if !rules::counts(track.duration_ms, played_ms) { + return Ok(false); + } + let Some(entry) = rules::entry_for(track, started_at_ms) else { + return Ok(false); + }; + let creds = self.credentials().await?; + if creds.accounts.is_empty() { + return Ok(false); + } + for service in creds.accounts.keys() { + queue::enqueue(&self.pool, *service, &entry).await?; + } + self.wake.notify_one(); + Ok(true) + } + + /// Mirrors a like or unlike as a love on the services that have one. + pub async fn love(&self, track: &TrackInfo, loved: bool) -> AppResult<()> { + let Some(love) = rules::love_for(track, loved) else { + return Ok(()); + }; + let creds = self.credentials().await?; + let mut queued = false; + for service in creds.accounts.keys().filter(|s| s.takes_loves()) { + queue::enqueue_love(&self.pool, *service, &love).await?; + queued = true; + } + if queued { + self.wake.notify_one(); + } + Ok(()) + } + + /// Sends every queued listen and love. False when something is left to retry. + pub async fn flush(&self) -> AppResult { + let _guard = self.flush_lock.lock().await; + let mut anything_waiting = false; + for service in Service::ALL { + anything_waiting |= queue::waiting(&self.pool, service).await? > 0; + } + if !anything_waiting { + return Ok(true); + } + let creds = self.credentials().await?; + let mut done = true; + for (service, account) in &creds.accounts { + if !self + .flush_service(*service, account, &creds.own_key) + .await? + { + done = false; + } + } + Ok(done) + } + + async fn flush_service( + &self, + service: Service, + account: &Account, + own_key: &OwnKey, + ) -> AppResult { + let keys = match service { + Service::LastFm => Self::last_fm_keys(own_key), + Service::LibreFm => Some(Keys::libre_fm()), + Service::ListenBrainz => None, + }; + let client = if service == Service::LibreFm { + &LIBRE_FM + } else { + &LAST_FM + }; + + if service.takes_loves() { + for love in queue::pending_loves(&self.pool, service).await? { + let outcome = match &keys { + Some(keys) => { + client + .send( + audioscrobbler::love_params(&love.item, &account.secret), + keys, + ) + .await + } + None => SendOutcome::SignedOut, + }; + match outcome { + SendOutcome::Sent => { + queue::drop_sent(&self.pool, Table::Loves, std::slice::from_ref(&love)) + .await?; + } + SendOutcome::Retry => return Ok(false), + SendOutcome::SignedOut => return self.signed_out(service).await, + } + } + } + + let batch_size = if service == Service::ListenBrainz { + listenbrainz::MAX_BATCH + } else { + audioscrobbler::MAX_BATCH + }; + loop { + let batch = queue::pending(&self.pool, service, batch_size).await?; + if batch.is_empty() { + return Ok(true); + } + let entries: Vec = batch.iter().map(|q| q.item.clone()).collect(); + let outcome = match (service, &keys) { + (Service::ListenBrainz, _) => listenbrainz::scrobble(&entries, account).await, + (_, Some(keys)) => { + client + .send( + audioscrobbler::scrobble_params(&entries, &account.secret), + keys, + ) + .await + } + (_, None) => SendOutcome::SignedOut, + }; + match outcome { + SendOutcome::Sent => queue::drop_sent(&self.pool, Table::Listens, &batch).await?, + SendOutcome::Retry => return Ok(false), + SendOutcome::SignedOut => return self.signed_out(service).await, + } + } + } + + async fn signed_out(&self, service: Service) -> AppResult { + tracing::warn!( + service = service.as_str(), + "Scrobbling service signed the account out" + ); + self.sign_out(service).await?; + Ok(true) + } +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scrobble::credentials::memory::MemoryStore; + use sqlx::sqlite::SqlitePoolOptions; + + async fn scrobbler() -> Scrobbler { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + Scrobbler::new(pool, Arc::new(MemoryStore::default())) + } + + fn track(duration_ms: u64) -> TrackInfo { + TrackInfo { + title: "Song".into(), + artists: vec!["Artist - Topic".into()], + album: None, + duration_ms, + } + } + + fn account() -> Account { + Account { + user_name: "listener".into(), + secret: "SK".into(), + } + } + + #[tokio::test] + async fn a_counted_listen_is_queued_for_each_signed_in_service() { + let scrobbler = scrobbler().await; + assert!( + !scrobbler + .listened(&track(200_000), 150_000, 0) + .await + .unwrap() + ); + + scrobbler + .set_account(Service::LastFm, Some(account())) + .await + .unwrap(); + scrobbler + .set_account(Service::ListenBrainz, Some(account())) + .await + .unwrap(); + assert!( + !scrobbler + .listened(&track(200_000), 99_000, 0) + .await + .unwrap() + ); + assert!(!scrobbler.listened(&track(20_000), 20_000, 0).await.unwrap()); + assert!( + scrobbler + .listened(&track(200_000), 100_000, 0) + .await + .unwrap() + ); + + let status = scrobbler.status().await.unwrap(); + let waiting: Vec<_> = status.services.iter().map(|s| s.waiting).collect(); + assert_eq!(waiting, [1, 0, 1]); + } + + #[tokio::test] + async fn loves_skip_listenbrainz_and_signing_out_drops_the_queue() { + let scrobbler = scrobbler().await; + scrobbler + .set_account(Service::LibreFm, Some(account())) + .await + .unwrap(); + scrobbler + .set_account(Service::ListenBrainz, Some(account())) + .await + .unwrap(); + scrobbler.love(&track(200_000), true).await.unwrap(); + assert_eq!( + queue::waiting(&scrobbler.pool, Service::LibreFm) + .await + .unwrap(), + 1 + ); + assert_eq!( + queue::waiting(&scrobbler.pool, Service::ListenBrainz) + .await + .unwrap(), + 0 + ); + + scrobbler.sign_out(Service::LibreFm).await.unwrap(); + let status = scrobbler.status().await.unwrap(); + assert!(!status.services[1].signed_in); + assert_eq!(status.services[1].waiting, 0); + } + + #[tokio::test] + async fn changing_the_own_key_signs_out_of_last_fm_only() { + let scrobbler = scrobbler().await; + scrobbler + .set_account(Service::LastFm, Some(account())) + .await + .unwrap(); + scrobbler + .set_account(Service::LibreFm, Some(account())) + .await + .unwrap(); + scrobbler + .set_own_key(true, Some(" abcdefghij ".into()), Some("s".into())) + .await + .unwrap(); + let status = scrobbler.status().await.unwrap(); + assert!(!status.services[0].signed_in); + assert!(status.services[1].signed_in); + assert!(status.last_fm_ready); + assert_eq!(status.own_key_hint.as_deref(), Some("abcdef")); + + // Saving the same key again keeps the session; switching back to the build's key does not. + scrobbler + .set_account(Service::LastFm, Some(account())) + .await + .unwrap(); + scrobbler.set_own_key(true, None, None).await.unwrap(); + assert!(scrobbler.status().await.unwrap().services[0].signed_in); + scrobbler.set_own_key(false, None, None).await.unwrap(); + let status = scrobbler.status().await.unwrap(); + assert!(!status.services[0].signed_in); + assert_eq!(status.own_key_hint.as_deref(), Some("abcdef")); + } + + #[tokio::test] + async fn a_build_without_a_key_cannot_sign_in_to_last_fm() { + if Keys::bundled_last_fm().is_usable() { + return; + } + let scrobbler = scrobbler().await; + let error = scrobbler + .sign_in(Service::LastFm, "u", "p") + .await + .unwrap_err(); + assert!(matches!(error, AppError::ScrobbleKeyMissing(_))); + assert!(!scrobbler.status().await.unwrap().last_fm_ready); + } + + #[tokio::test] + async fn flushing_an_empty_queue_never_touches_the_network() { + let scrobbler = scrobbler().await; + scrobbler + .set_account(Service::LastFm, Some(account())) + .await + .unwrap(); + assert!(scrobbler.flush().await.unwrap()); + } +} diff --git a/src-tauri/src/scrobble/worker.rs b/src-tauri/src/scrobble/worker.rs new file mode 100644 index 00000000..177e9f81 --- /dev/null +++ b/src-tauri/src/scrobble/worker.rs @@ -0,0 +1,56 @@ +//! Sends the queued listens in the background: once at launch, whenever a listen is queued, and +//! again with exponential backoff while a service is down or the network is gone. + +use std::sync::Arc; +use std::time::Duration; + +use super::scrobbler::Scrobbler; + +const FIRST_RETRY: Duration = Duration::from_mins(5); +const LONGEST_RETRY: Duration = Duration::from_hours(6); + +pub fn next_backoff(previous: Option) -> Duration { + previous.map_or(FIRST_RETRY, |delay| (delay * 2).min(LONGEST_RETRY)) +} + +pub fn spawn(scrobbler: Arc) { + tauri::async_runtime::spawn(async move { + let mut backoff: Option = None; + loop { + let done = match scrobbler.flush().await { + Ok(done) => done, + Err(error) => { + tracing::warn!(%error, "Scrobble flush failed"); + false + } + }; + backoff = if done { + None + } else { + Some(next_backoff(backoff)) + }; + match backoff { + None => scrobbler.wake.notified().await, + Some(delay) => { + tokio::select! { + () = tokio::time::sleep(delay) => {} + () = scrobbler.wake.notified() => {} + } + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_doubles_from_five_minutes_up_to_six_hours() { + let first = next_backoff(None); + assert_eq!(first, FIRST_RETRY); + assert_eq!(next_backoff(Some(first)), Duration::from_mins(10)); + assert_eq!(next_backoff(Some(LONGEST_RETRY)), LONGEST_RETRY); + } +} From 28ad70e19b6c091cbb5df98a2735eb301363e1d6 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:51:16 +0300 Subject: [PATCH 2/6] feat(integrations): add scrobbling commands and the background send worker --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/scrobble.rs | 83 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 22 +++++++- 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/commands/scrobble.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f321cd30..bcdfad6a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod music_queue; pub mod notifications; pub mod pip; pub mod recommendation; +pub mod scrobble; pub mod shorts; pub mod sync; pub mod tray; diff --git a/src-tauri/src/commands/scrobble.rs b/src-tauri/src/commands/scrobble.rs new file mode 100644 index 00000000..343f3c78 --- /dev/null +++ b/src-tauri/src/commands/scrobble.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use tauri::State; + +use crate::errors::ErrorResponse; +use crate::scrobble::models::Service; +use crate::scrobble::rules::TrackInfo; +use crate::scrobble::scrobbler::{ScrobbleStatus, Scrobbler}; + +type Scrobbling<'a> = State<'a, Arc>; + +/// Accounts, waiting listens and whether Last.fm sign-in has a key. +#[tauri::command] +pub async fn scrobble_status(scrobbler: Scrobbling<'_>) -> Result { + Ok(scrobbler.status().await?) +} + +/// Sign in with a user name and password, or a `ListenBrainz` user token in `secret`. +#[tauri::command] +pub async fn scrobble_sign_in( + service: Service, + user_name: String, + secret: String, + scrobbler: Scrobbling<'_>, +) -> Result<(), ErrorResponse> { + Ok(scrobbler.sign_in(service, &user_name, &secret).await?) +} + +#[tauri::command] +pub async fn scrobble_sign_out( + service: Service, + scrobbler: Scrobbling<'_>, +) -> Result<(), ErrorResponse> { + Ok(scrobbler.sign_out(service).await?) +} + +/// Use the listener's own Last.fm API key, or go back to the build's. A missing key or +/// secret keeps the stored one. +#[tauri::command] +pub async fn scrobble_set_own_key( + enabled: bool, + api_key: Option, + secret: Option, + scrobbler: Scrobbling<'_>, +) -> Result<(), ErrorResponse> { + Ok(scrobbler.set_own_key(enabled, api_key, secret).await?) +} + +/// The song started playing for real. +#[tauri::command] +pub async fn scrobble_now_playing( + track: TrackInfo, + scrobbler: Scrobbling<'_>, +) -> Result<(), ErrorResponse> { + Ok(scrobbler.now_playing(&track).await?) +} + +/// The song has played long enough to count; queued at once so a later quit cannot lose it. +#[tauri::command] +pub async fn scrobble_listened( + track: TrackInfo, + played_ms: u64, + started_at_ms: i64, + scrobbler: Scrobbling<'_>, +) -> Result { + Ok(scrobbler.listened(&track, played_ms, started_at_ms).await?) +} + +/// A song was liked or unliked. +#[tauri::command] +pub async fn scrobble_love( + track: TrackInfo, + loved: bool, + scrobbler: Scrobbling<'_>, +) -> Result<(), ErrorResponse> { + Ok(scrobbler.love(&track, loved).await?) +} + +/// Send waiting listens now. False when some are still waiting. +#[tauri::command] +pub async fn scrobble_flush(scrobbler: Scrobbling<'_>) -> Result { + Ok(scrobbler.flush().await?) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9bc1c9b0..d5c78e6d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ mod linux_startup; mod macos_window; pub mod models; mod music_brain; +mod scrobble; mod security; mod services; mod streaming; @@ -90,6 +91,10 @@ use commands::recommendation::{ record_feed_impressions, remove_preferred_topic, report_watch_progress, reset_brain, resolve_hidden_channel_names, unblock_channel, unblock_topic, }; +use commands::scrobble::{ + scrobble_flush, scrobble_listened, scrobble_love, scrobble_now_playing, scrobble_set_own_key, + scrobble_sign_in, scrobble_sign_out, scrobble_status, +}; use commands::shorts::{ get_shorts_feed, load_more_shorts, record_seen_shorts, report_short_view, reset_shorts_feed, }; @@ -326,6 +331,12 @@ pub fn run() { // Manage database pool app.manage(pool.clone()); + let scrobbler = Arc::new(scrobble::scrobbler::Scrobbler::new( + pool.clone(), + Arc::new(scrobble::credentials::Keychain), + )); + scrobble::worker::spawn(Arc::clone(&scrobbler)); + app.manage(scrobbler); app.manage(MusicService::new(extractor, pool.clone())); let music_pool = pool.clone(); @@ -649,7 +660,16 @@ pub fn run() { // --- Music tray --- music_tray_supported, music_tray_update, - music_tray_hide + music_tray_hide, + // --- Scrobbling --- + scrobble_status, + scrobble_sign_in, + scrobble_sign_out, + scrobble_set_own_key, + scrobble_now_playing, + scrobble_listened, + scrobble_love, + scrobble_flush ]) .build(context) .expect("error while building Flow Desktop") From d252fb665e9e2c180889dc5bb080f84166bb99b0 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:51:26 +0300 Subject: [PATCH 3/6] ci: pass the Last.fm API key and secret to app builds --- .github/workflows/build.yml | 4 ++++ .github/workflows/release.yml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b79e004..4768c8bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -270,6 +270,10 @@ jobs: run: node scripts/write-nightly-config.mjs - name: Build nightly packages + env: + # Read at compile time; without them Last.fm sign-in stays off in the build. + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_API_SECRET: ${{ secrets.LASTFM_API_SECRET }} run: pnpm tauri build ${{ matrix.args }} --config src-tauri/tauri.nightly.generated.json - name: Log the bundled media stack versions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 980caf49..482e5fda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -211,6 +211,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # Read at compile time; without them Last.fm sign-in stays off in the build. + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_API_SECRET: ${{ secrets.LASTFM_API_SECRET }} with: tagName: ${{ needs.prepare.outputs.tag }} releaseName: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'Flow Beta v__VERSION__' || 'Flow v__VERSION__' }} From 2f4d587fea869465b5ae79deddd011018ca8d040 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:57:31 +0300 Subject: [PATCH 4/6] feat(integrations): report music listens, now playing and likes for scrobbling --- src/App.tsx | 2 + .../scrobbling/ScrobbleController.tsx | 7 ++ src/lib/api/scrobble.ts | 93 +++++++++++++++ src/lib/scrobble/likeChange.test.ts | 40 +++++++ src/lib/scrobble/likeChange.ts | 30 +++++ src/lib/scrobble/listenTracker.test.ts | 100 ++++++++++++++++ src/lib/scrobble/listenTracker.ts | 110 ++++++++++++++++++ src/lib/settings/schema.ts | 6 + src/lib/useScrobbleReporter.ts | 70 +++++++++++ 9 files changed, 458 insertions(+) create mode 100644 src/components/scrobbling/ScrobbleController.tsx create mode 100644 src/lib/api/scrobble.ts create mode 100644 src/lib/scrobble/likeChange.test.ts create mode 100644 src/lib/scrobble/likeChange.ts create mode 100644 src/lib/scrobble/listenTracker.test.ts create mode 100644 src/lib/scrobble/listenTracker.ts create mode 100644 src/lib/useScrobbleReporter.ts diff --git a/src/App.tsx b/src/App.tsx index 0cbd04cf..56443efb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,7 @@ import { DeepFlowController } from "./components/deep-flow/DeepFlowController"; import { DeepLinkController } from "./components/handoff/DeepLinkController"; import { DiscordPresenceController } from "./components/discord/DiscordPresenceController"; import { MusicTrayController } from "./components/tray/MusicTrayController"; +import { ScrobbleController } from "./components/scrobbling/ScrobbleController"; import { DownloadDialog } from "./components/downloads/DownloadDialog"; import { DownloadActivity } from "./components/downloads/DownloadActivity"; import { DonationPromptHost } from "./components/donations/DonationPrompt"; @@ -272,6 +273,7 @@ function App() { + diff --git a/src/components/scrobbling/ScrobbleController.tsx b/src/components/scrobbling/ScrobbleController.tsx new file mode 100644 index 00000000..5aa4d855 --- /dev/null +++ b/src/components/scrobbling/ScrobbleController.tsx @@ -0,0 +1,7 @@ +import { useScrobbleReporter } from "../../lib/useScrobbleReporter"; + +// Headless controller that reports music listens to the scrobbling backend. +export function ScrobbleController() { + useScrobbleReporter(); + return null; +} diff --git a/src/lib/api/scrobble.ts b/src/lib/api/scrobble.ts new file mode 100644 index 00000000..f30b06e3 --- /dev/null +++ b/src/lib/api/scrobble.ts @@ -0,0 +1,93 @@ +import { invokeBackend } from "./errors"; +import { isTauriEnv } from "./env"; +import type { SongItem } from "../../types/music"; + +export type ScrobbleService = "LASTFM" | "LIBREFM" | "LISTENBRAINZ"; + +export const SCROBBLE_SERVICES: readonly ScrobbleService[] = ["LASTFM", "LIBREFM", "LISTENBRAINZ"]; + +// Mirrors the backend ScrobbleStatus (serde camelCase). +export interface ScrobbleServiceStatus { + service: ScrobbleService; + signedIn: boolean; + userName: string | null; + waiting: number; +} + +export interface ScrobbleStatus { + services: ScrobbleServiceStatus[]; + lastFmReady: boolean; + ownKeyEnabled: boolean; + ownKeyHint: string | null; + keychainError: string | null; +} + +// Mirrors the backend TrackInfo. +export interface ScrobbleTrack { + title: string; + artists: string[]; + album: string | null; + durationMs: number; +} + +export function scrobbleTrack(song: SongItem, durationMs: number): ScrobbleTrack { + return { + title: song.title, + artists: song.artists.map((artist) => artist.name), + album: song.album?.name ?? null, + durationMs: Math.max(0, Math.round(durationMs)), + }; +} + +export function getScrobbleStatus(): Promise { + return invokeBackend("scrobble_status"); +} + +/** `secret` is the password, or the user token for ListenBrainz. */ +export function scrobbleSignIn(service: ScrobbleService, userName: string, secret: string): Promise { + return invokeBackend("scrobble_sign_in", { service, userName, secret }); +} + +export function scrobbleSignOut(service: ScrobbleService): Promise { + return invokeBackend("scrobble_sign_out", { service }); +} + +/** Leaving out the key and secret keeps the stored ones, so the switch alone can be flipped. */ +export function setScrobbleOwnKey(enabled: boolean, keys?: { apiKey: string; secret: string }): Promise { + return invokeBackend("scrobble_set_own_key", { + enabled, + apiKey: keys?.apiKey ?? null, + secret: keys?.secret ?? null, + }); +} + +/** Sends waiting listens now; false when some are still waiting. */ +export function flushScrobbles(): Promise { + return invokeBackend("scrobble_flush"); +} + +// Player reports are fire-and-forget: a failed report must never disturb playback. +async function report(command: string, args: Record): Promise { + if (!(await isTauriEnv())) return; + try { + await invokeBackend(command, args); + } catch (error) { + console.warn(`Scrobbling report ${command} failed`, error); + } +} + +export function reportNowPlaying(track: ScrobbleTrack): Promise { + return report("scrobble_now_playing", { track }); +} + +export function reportListened(track: ScrobbleTrack, playedMs: number, startedAtMs: number): Promise { + return report("scrobble_listened", { track, playedMs: Math.round(playedMs), startedAtMs }); +} + +export function reportLove(track: ScrobbleTrack, loved: boolean): Promise { + return report("scrobble_love", { track, loved }); +} + +export function reportOnline(): Promise { + return report("scrobble_flush", {}); +} diff --git a/src/lib/scrobble/likeChange.test.ts b/src/lib/scrobble/likeChange.test.ts new file mode 100644 index 00000000..fb1efb5e --- /dev/null +++ b/src/lib/scrobble/likeChange.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { likeChange } from "./likeChange"; +import type { LikedItem } from "../../store/useLikesStore"; +import type { SongItem } from "../../types/music"; + +const song = (id: string): SongItem => ({ + id, + title: id, + artists: [{ name: "Artist", id: null }], + album: null, + duration: 200, + musicVideoType: null, + thumbnail: "", + explicit: false, + videoId: id, + playlistId: null, + params: null, +}); + +const liked = (id: string): LikedItem => ({ kind: "music", id, likedAt: "2026-10-24T00:00:00Z", song: song(id) }); +const video: LikedItem = { + kind: "video", + id: "v", + likedAt: "2026-10-24T00:00:00Z", + video: {} as never, +}; + +describe("likeChange", () => { + it("reports one song liked or unliked", () => { + const before = { loaded: true, items: [liked("a")] }; + expect(likeChange(before, { loaded: true, items: [liked("b"), liked("a")] })).toEqual({ song: song("b"), liked: true }); + expect(likeChange(before, { loaded: true, items: [] })).toEqual({ song: song("a"), liked: false }); + }); + + it("ignores the library loading, bulk changes and video likes", () => { + expect(likeChange({ loaded: false, items: [] }, { loaded: true, items: [liked("a")] })).toBeNull(); + expect(likeChange({ loaded: true, items: [] }, { loaded: true, items: [liked("a"), liked("b")] })).toBeNull(); + expect(likeChange({ loaded: true, items: [] }, { loaded: true, items: [video] })).toBeNull(); + }); +}); diff --git a/src/lib/scrobble/likeChange.ts b/src/lib/scrobble/likeChange.ts new file mode 100644 index 00000000..4906ee75 --- /dev/null +++ b/src/lib/scrobble/likeChange.ts @@ -0,0 +1,30 @@ +import type { LikedItem } from "../../store/useLikesStore"; +import type { SongItem } from "../../types/music"; + +export interface LikeChange { + song: SongItem; + liked: boolean; +} + +interface LikesSnapshot { + items: readonly LikedItem[]; + loaded: boolean; +} + +const songsById = (items: readonly LikedItem[]) => + new Map(items.flatMap((item) => (item.kind === "music" ? [[item.id, item.song] as const] : []))); + +/** + * The song a single like or unlike changed. Loading the library, a sync or a restore changes + * many songs at once, and those are not the listener's likes, so they give nothing. + */ +export function likeChange(previous: LikesSnapshot, next: LikesSnapshot): LikeChange | null { + if (!previous.loaded || !next.loaded || previous.items === next.items) return null; + const before = songsById(previous.items); + const after = songsById(next.items); + const added = [...after].filter(([id]) => !before.has(id)); + const removed = [...before].filter(([id]) => !after.has(id)); + if (added.length + removed.length !== 1) return null; + const [entry, liked] = added.length === 1 ? [added[0]!, true] : [removed[0]!, false]; + return { song: entry[1], liked }; +} diff --git a/src/lib/scrobble/listenTracker.test.ts b/src/lib/scrobble/listenTracker.test.ts new file mode 100644 index 00000000..02d4c45d --- /dev/null +++ b/src/lib/scrobble/listenTracker.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { ListenTracker, listenThresholdMs, type PlayerSnapshot } from "./listenTracker"; +import type { SongItem } from "../../types/music"; + +const song = (id: string, duration = 200): SongItem => ({ + id, + title: `Song ${id}`, + artists: [{ name: "Artist", id: null }], + album: null, + duration, + musicVideoType: null, + thumbnail: "", + explicit: false, + videoId: id, + playlistId: null, + params: null, +}); + +const snap = (track: SongItem | null, progress: number, extra: Partial = {}): PlayerSnapshot => ({ + track, + playing: true, + buffering: false, + progress, + duration: track?.duration ?? 0, + ...extra, +}); + +/** Plays `seconds` of audio in quarter-second steps, as timeupdate would. */ +function play(tracker: ListenTracker, track: SongItem, from: number, seconds: number, startMs: number) { + const events = []; + for (let step = 0; step <= seconds * 4; step += 1) { + events.push(...tracker.update(snap(track, from + step / 4), startMs + step * 250)); + } + return events; +} + +describe("listen threshold", () => { + it("is half the track, capped at four minutes, and absent for short tracks", () => { + expect(listenThresholdMs(200_000)).toBe(100_000); + expect(listenThresholdMs(20 * 60_000)).toBe(4 * 60_000); + expect(listenThresholdMs(30_000)).toBeNull(); + }); +}); + +describe("ListenTracker", () => { + it("announces now playing once, when audio really starts", () => { + const tracker = new ListenTracker(); + const track = song("a"); + expect(tracker.update(snap(track, 0, { buffering: true }), 0)).toEqual([]); + expect(tracker.update(snap(track, 0), 100)).toEqual([]); + const started = tracker.update(snap(track, 0.25), 350); + expect(started.map((e) => e.kind)).toEqual(["nowPlaying"]); + expect(play(tracker, track, 0.5, 10, 600).some((e) => e.kind === "nowPlaying")).toBe(false); + }); + + it("counts the listen the moment half the song has played", () => { + const tracker = new ListenTracker(); + const track = song("a"); + const events = play(tracker, track, 0, 101, 1_000_000); + const listened = events.filter((e) => e.kind === "listened"); + expect(listened).toHaveLength(1); + expect(listened[0]).toMatchObject({ durationMs: 200_000, startedAtMs: 1_000_000 }); + expect(listened[0]!.kind === "listened" && listened[0]!.playedMs).toBeGreaterThanOrEqual(100_000); + }); + + it("does not count paused, buffering or skipped time", () => { + const tracker = new ListenTracker(); + const track = song("a"); + play(tracker, track, 0, 60, 0); + tracker.update(snap(track, 60, { playing: false }), 60_000); + tracker.update(snap(track, 60, { playing: false }), 600_000); + tracker.update(snap(track, 150), 600_250); + const after = play(tracker, track, 150, 30, 600_500); + expect(after.some((e) => e.kind === "listened")).toBe(false); + }); + + it("never counts songs of 30 seconds or less", () => { + const tracker = new ListenTracker(); + expect(play(tracker, song("short", 30), 0, 30, 0).some((e) => e.kind === "listened")).toBe(false); + }); + + it("starts a new listen on the next song and on a repeat", () => { + const tracker = new ListenTracker(); + const first = song("a", 60); + expect(play(tracker, first, 0, 59, 0).filter((e) => e.kind === "listened")).toHaveLength(1); + const again = play(tracker, first, 0, 31, 100_000); + expect(again.filter((e) => e.kind === "nowPlaying")).toHaveLength(1); + expect(again.filter((e) => e.kind === "listened")).toHaveLength(1); + const next = play(tracker, song("b", 60), 0, 31, 200_000); + expect(next.map((e) => e.kind)).toEqual(["nowPlaying", "listened"]); + }); + + it("ignores a long gap between updates, such as the machine sleeping", () => { + const tracker = new ListenTracker(); + const track = song("a"); + tracker.update(snap(track, 0), 0); + tracker.update(snap(track, 0.25), 250); + expect(tracker.update(snap(track, 0.5), 3_600_000)).toEqual([]); + }); +}); diff --git a/src/lib/scrobble/listenTracker.ts b/src/lib/scrobble/listenTracker.ts new file mode 100644 index 00000000..e90eb868 --- /dev/null +++ b/src/lib/scrobble/listenTracker.ts @@ -0,0 +1,110 @@ +import type { SongItem } from "../../types/music"; + +const MIN_DURATION_MS = 30_000; +const MAX_THRESHOLD_MS = 4 * 60_000; +// A store update normally lands every quarter second; a longer gap means the machine slept or +// the clock jumped, which is not listening. +const MAX_STEP_MS = 5_000; +// Progress falling from the last seconds back to the start of the same song is a repeat. +const REPEAT_WINDOW_SECONDS = 5; + +/** Mirrors the backend rule: half the track or four minutes, and never for 30 s or less. */ +export function listenThresholdMs(durationMs: number): number | null { + return durationMs > MIN_DURATION_MS ? Math.min(Math.floor(durationMs / 2), MAX_THRESHOLD_MS) : null; +} + +export interface PlayerSnapshot { + track: SongItem | null; + playing: boolean; + buffering: boolean; + /** Seconds, from the element's timeupdate. */ + progress: number; + /** Seconds, from the element; 0 until known. */ + duration: number; +} + +export type ListenEvent = + | { kind: "nowPlaying"; track: SongItem; durationMs: number } + | { kind: "listened"; track: SongItem; durationMs: number; playedMs: number; startedAtMs: number }; + +interface Session { + key: string; + track: SongItem; + startedAtMs: number; + startProgress: number; + lastProgress: number; + playedMs: number; + audibleSince: number | null; + announced: boolean; + counted: boolean; +} + +const songKey = (song: SongItem) => song.videoId ?? song.id; + +function durationMs(snapshot: PlayerSnapshot, track: SongItem): number { + const seconds = snapshot.duration > 0 ? snapshot.duration : track.duration ?? 0; + return seconds * 1000; +} + +/** + * Counts one listen per play of a song by the time it was actually audible: pauses, buffering + * and seeks don't count, and a repeat starts a new listen. Now playing goes out once audio + * really starts; the listen goes out the moment it reaches the threshold. + */ +export class ListenTracker { + private session: Session | null = null; + + update(snapshot: PlayerSnapshot, nowMs: number): ListenEvent[] { + const { track } = snapshot; + if (!track) { + this.session = null; + return []; + } + + let session = this.session; + const repeated = session !== null + && session.key === songKey(track) + && snapshot.progress + REPEAT_WINDOW_SECONDS < session.lastProgress + && session.lastProgress >= durationMs(snapshot, track) / 1000 - REPEAT_WINDOW_SECONDS; + if (!session || session.key !== songKey(track) || repeated) { + session = { + key: songKey(track), + track, + startedAtMs: nowMs, + startProgress: snapshot.progress, + lastProgress: snapshot.progress, + playedMs: 0, + audibleSince: null, + announced: false, + counted: false, + }; + this.session = session; + } + + if (session.audibleSince !== null) { + session.playedMs += Math.min(Math.max(0, nowMs - session.audibleSince), MAX_STEP_MS); + } + const audible = snapshot.playing && !snapshot.buffering; + session.audibleSince = audible ? nowMs : null; + + const events: ListenEvent[] = []; + const length = durationMs(snapshot, track); + if (!session.announced && audible && snapshot.progress !== session.startProgress) { + session.announced = true; + events.push({ kind: "nowPlaying", track, durationMs: length }); + } + const threshold = listenThresholdMs(length); + if (!session.counted && threshold !== null && session.playedMs >= threshold) { + session.counted = true; + events.push({ + kind: "listened", + track, + durationMs: length, + playedMs: session.playedMs, + startedAtMs: session.startedAtMs, + }); + } + session.lastProgress = snapshot.progress; + return events; + } +} diff --git a/src/lib/settings/schema.ts b/src/lib/settings/schema.ts index 96a1fec2..4ebb31ab 100644 --- a/src/lib/settings/schema.ts +++ b/src/lib/settings/schema.ts @@ -152,6 +152,9 @@ export const SETTINGS = { RYTD_ENABLED: "rytd_enabled", DISCORD_RPC_MODE: "discord_rpc_mode", MUSIC_TRAY_ENABLED: "music_tray_enabled", + SCROBBLE_NOW_PLAYING: "scrobble_now_playing", + SCROBBLE_LOVE_LIKES: "scrobble_love_likes", + DEEP_FLOW_SCROBBLE: "deep_flow_scrobble", SB_SUBMIT_ENABLED: "sb_submit_enabled", SPONSORBLOCK_USER_ID: "sponsorblock_user_id", SPONSORBLOCK_SERVER: "sponsorblock_server", @@ -375,6 +378,9 @@ export const SETTING_DEFINITIONS = [ str(SETTINGS.DISCORD_RPC_MODE, "extensions", "off", "wired", ["off", "music", "videos", "musicAndVideos"]), // Linux trays need an AppIndicator library and a desktop that shows them, so it is opt-in there. bool(SETTINGS.MUSIC_TRAY_ENABLED, "extensions", !IS_LINUX_RUNTIME, "wired"), + bool(SETTINGS.SCROBBLE_NOW_PLAYING, "extensions", true, "wired"), + bool(SETTINGS.SCROBBLE_LOVE_LIKES, "extensions", false, "wired"), + bool(SETTINGS.DEEP_FLOW_SCROBBLE, "extensions", false, "wired"), bool(SETTINGS.SB_SUBMIT_ENABLED, "extensions", false, "wired"), str(SETTINGS.SPONSORBLOCK_USER_ID, "extensions", "", "persisted-only", undefined, "visible", "private"), str(SETTINGS.SPONSORBLOCK_SERVER, "extensions", "https://sponsor.ajay.app", "wired"), diff --git a/src/lib/useScrobbleReporter.ts b/src/lib/useScrobbleReporter.ts new file mode 100644 index 00000000..5cd0a0b8 --- /dev/null +++ b/src/lib/useScrobbleReporter.ts @@ -0,0 +1,70 @@ +import { useEffect } from "react"; + +import { useMusicPlayerStore } from "../store/useMusicPlayerStore"; +import { useLikesStore } from "../store/useLikesStore"; +import { getSettingValue } from "../store/useAppSettingsStore"; +import { isTauriEnv } from "./api/env"; +import { reportListened, reportLove, reportNowPlaying, reportOnline, scrobbleTrack } from "./api/scrobble"; +import { isDeepFlowCurrentlyActive } from "./deepFlow"; +import { likeChange } from "./scrobble/likeChange"; +import { ListenTracker, type ListenEvent } from "./scrobble/listenTracker"; +import { SETTINGS, type SettingKey } from "./settings/schema"; + +const enabled = (key: SettingKey) => getSettingValue(key) === "true"; + +/** As on Android, Deep Flow keeps listens private unless the listener allowed them. */ +function scrobblingAllowed(): boolean { + return !isDeepFlowCurrentlyActive() || enabled(SETTINGS.DEEP_FLOW_SCROBBLE); +} + +function send(event: ListenEvent): void { + if (!scrobblingAllowed()) return; + const track = scrobbleTrack(event.track, event.durationMs); + if (event.kind === "nowPlaying") { + if (enabled(SETTINGS.SCROBBLE_NOW_PLAYING)) void reportNowPlaying(track); + } else { + void reportListened(track, event.playedMs, event.startedAtMs); + } +} + +// Reports what the music player plays to the scrobbling backend. Only the music player feeds +// it, so videos are never scrobbled. +export function useScrobbleReporter(): void { + useEffect(() => { + let disposed = false; + const cleanups: (() => void)[] = []; + + void isTauriEnv().then((inTauri) => { + if (!inTauri || disposed) return; + const tracker = new ListenTracker(); + const observe = () => { + const player = useMusicPlayerStore.getState(); + const events = tracker.update({ + track: player.currentTrack, + playing: player.isPlaying, + buffering: player.isBuffering, + progress: player.progress, + duration: player.duration, + }, Date.now()); + events.forEach(send); + }; + observe(); + cleanups.push(useMusicPlayerStore.subscribe(observe)); + + cleanups.push(useLikesStore.subscribe((next, previous) => { + const change = likeChange(previous, next); + if (!change || !enabled(SETTINGS.SCROBBLE_LOVE_LIKES)) return; + void reportLove(scrobbleTrack(change.song, (change.song.duration ?? 0) * 1000), change.liked); + })); + + const onOnline = () => void reportOnline(); + window.addEventListener("online", onOnline); + cleanups.push(() => window.removeEventListener("online", onOnline)); + }); + + return () => { + disposed = true; + cleanups.forEach((cleanup) => cleanup()); + }; + }, []); +} From c6a2d0c29cb9465f88f2634d4d2edda6b33c0973 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:57:31 +0300 Subject: [PATCH 5/6] feat(integrations): add the scrobbling section to Integrations --- src/components/scrobbling/ScrobbleDialog.tsx | 40 ++++++ .../scrobbling/ScrobbleOwnKeyDialog.tsx | 48 +++++++ .../scrobbling/ScrobbleSignInDialog.tsx | 67 +++++++++ .../scrobbling/ScrobblingSection.tsx | 135 ++++++++++++++++++ src/lib/i18n/strings.json | 44 ++++++ src/lib/useScrobbleSettings.test.ts | 38 +++++ src/lib/useScrobbleSettings.ts | 95 ++++++++++++ src/pages/ExtensionsPage.tsx | 5 + 8 files changed, 472 insertions(+) create mode 100644 src/components/scrobbling/ScrobbleDialog.tsx create mode 100644 src/components/scrobbling/ScrobbleOwnKeyDialog.tsx create mode 100644 src/components/scrobbling/ScrobbleSignInDialog.tsx create mode 100644 src/components/scrobbling/ScrobblingSection.tsx create mode 100644 src/lib/useScrobbleSettings.test.ts create mode 100644 src/lib/useScrobbleSettings.ts diff --git a/src/components/scrobbling/ScrobbleDialog.tsx b/src/components/scrobbling/ScrobbleDialog.tsx new file mode 100644 index 00000000..7968f5e5 --- /dev/null +++ b/src/components/scrobbling/ScrobbleDialog.tsx @@ -0,0 +1,40 @@ +import { useEffect, type FormEvent, type ReactNode } from "react"; +import { Button } from "../ui/Button"; +import { getString } from "../../lib/i18n/index"; + +interface ScrobbleDialogProps { + title: string; + submitLabel: string; + submitDisabled: boolean; + onSubmit: () => void; + onCancel: () => void; + children: ReactNode; +} + +export function ScrobbleDialog({ title, submitLabel, submitDisabled, onSubmit, onCancel, children }: ScrobbleDialogProps) { + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onCancel]); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!submitDisabled) onSubmit(); + }; + + return ( +
+
+

{title}

+
{children}
+
+ + +
+
+
+ ); +} diff --git a/src/components/scrobbling/ScrobbleOwnKeyDialog.tsx b/src/components/scrobbling/ScrobbleOwnKeyDialog.tsx new file mode 100644 index 00000000..a0faaab3 --- /dev/null +++ b/src/components/scrobbling/ScrobbleOwnKeyDialog.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { TextInput } from "../ui/TextInput"; +import { ScrobbleDialog } from "./ScrobbleDialog"; +import { getString } from "../../lib/i18n/index"; + +interface ScrobbleOwnKeyDialogProps { + onSave: (apiKey: string, secret: string) => Promise; + onClose: () => void; +} + +const fieldLabel = "block text-xs font-semibold uppercase tracking-widest text-chrome-neutral-500"; + +// The stored key is never read back into the page; saving replaces it. +export function ScrobbleOwnKeyDialog({ onSave, onClose }: ScrobbleOwnKeyDialogProps) { + const [apiKey, setApiKey] = useState(""); + const [secret, setSecret] = useState(""); + const [working, setWorking] = useState(false); + const [failed, setFailed] = useState(false); + + const save = async () => { + setWorking(true); + const saved = await onSave(apiKey, secret); + setWorking(false); + if (saved) onClose(); + else setFailed(true); + }; + + return ( + void save()} + onCancel={onClose} + > +

{getString("scrobbling_own_key_dialog_body")}

+ + + {failed &&

{getString("scrobbling_own_key_failed")}

} +
+ ); +} diff --git a/src/components/scrobbling/ScrobbleSignInDialog.tsx b/src/components/scrobbling/ScrobbleSignInDialog.tsx new file mode 100644 index 00000000..753d842c --- /dev/null +++ b/src/components/scrobbling/ScrobbleSignInDialog.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { TextInput } from "../ui/TextInput"; +import { ScrobbleDialog } from "./ScrobbleDialog"; +import { getString } from "../../lib/i18n/index"; + +interface ScrobbleSignInDialogProps { + serviceName: string; + /** ListenBrainz signs in with a user token instead of a user name and password. */ + usesToken: boolean; + /** Resolves to null on success, or the message to show. */ + onSubmit: (userName: string, secret: string) => Promise; + onClose: () => void; +} + +const fieldLabel = "block text-xs font-semibold uppercase tracking-widest text-chrome-neutral-500"; + +export function ScrobbleSignInDialog({ serviceName, usesToken, onSubmit, onClose }: ScrobbleSignInDialogProps) { + const [userName, setUserName] = useState(""); + const [secret, setSecret] = useState(""); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + + const submit = async () => { + setWorking(true); + setError(null); + const failure = await onSubmit(userName, secret); + setWorking(false); + if (failure) { + setError(failure); + return; + } + setSecret(""); + onClose(); + }; + + return ( + void submit()} + onCancel={onClose} + > + {!usesToken && ( + + )} + +

+ {error ?? getString(usesToken ? "scrobbling_token_note" : "scrobbling_password_note")} +

+
+ ); +} diff --git a/src/components/scrobbling/ScrobblingSection.tsx b/src/components/scrobbling/ScrobblingSection.tsx new file mode 100644 index 00000000..3f4860fa --- /dev/null +++ b/src/components/scrobbling/ScrobblingSection.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { SettingsGroup } from "../settings/SettingsGroup"; +import { SettingItem } from "../settings/SettingItem"; +import { Button } from "../ui/Button"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { ScrobbleSignInDialog } from "./ScrobbleSignInDialog"; +import { ScrobbleOwnKeyDialog } from "./ScrobbleOwnKeyDialog"; +import { confirmAction } from "../../lib/dialogs"; +import { getString, type StringKey } from "../../lib/i18n/index"; +import { SETTINGS } from "../../lib/settings/schema"; +import { useBoolPref } from "../../lib/usePreference"; +import { useScrobbleSettings } from "../../lib/useScrobbleSettings"; +import { SCROBBLE_SERVICES, type ScrobbleService, type ScrobbleServiceStatus } from "../../lib/api/scrobble"; +import { useUiStore } from "../../store/useUiStore"; + +const SERVICE_NAME: Record = { + LASTFM: "scrobbling_lastfm", + LIBREFM: "scrobbling_librefm", + LISTENBRAINZ: "scrobbling_listenbrainz", +}; + +function accountLabel(status: ScrobbleServiceStatus | undefined, ready: boolean): string { + if (status?.signedIn) { + return status.userName ? getString("scrobbling_signed_in_as", status.userName) : getString("scrobbling_signed_in"); + } + return ready ? getString("scrobbling_not_signed_in") : getString("scrobbling_needs_key"); +} + +export function ScrobblingSection() { + const { status, loadFailed, signIn, signOut, saveOwnKey, sendNow } = useScrobbleSettings(); + const showToast = useUiStore((state) => state.showToast); + const [nowPlaying, setNowPlaying] = useBoolPref(SETTINGS.SCROBBLE_NOW_PLAYING); + const [loveLikes, setLoveLikes] = useBoolPref(SETTINGS.SCROBBLE_LOVE_LIKES); + const [deepFlow, setDeepFlow] = useBoolPref(SETTINGS.DEEP_FLOW_SCROBBLE); + const [signingIn, setSigningIn] = useState(null); + const [editingKey, setEditingKey] = useState(false); + const [sending, setSending] = useState(false); + + const keychainBroken = !!status?.keychainError; + const waiting = status?.services.reduce((sum, service) => sum + service.waiting, 0) ?? 0; + + const confirmSignOut = async (service: ScrobbleService) => { + const name = getString(SERVICE_NAME[service]); + if (!await confirmAction(getString("scrobbling_sign_out_confirm", name), name)) return; + if (!await signOut(service)) showToast({ message: getString("scrobbling_sign_out_failed"), variant: "error" }); + }; + + const toggleOwnKey = async (enabled: boolean) => { + if (!await saveOwnKey(enabled)) showToast({ message: getString("scrobbling_own_key_failed"), variant: "error" }); + }; + + const sendWaiting = async () => { + setSending(true); + const done = await sendNow(); + setSending(false); + showToast(done + ? { message: getString("scrobbling_send_now_done"), variant: "success" } + : { message: getString("scrobbling_send_now_waiting"), variant: "info" }); + }; + + const description = loadFailed + ? getString("scrobbling_status_failed") + : keychainBroken + ? getString("scrobbling_keychain_unavailable") + : getString("scrobbling_summary"); + + return ( +
+ +

{description}

+ {SCROBBLE_SERVICES.map((service) => { + const serviceStatus = status?.services.find((item) => item.service === service); + const ready = service !== "LASTFM" || !!status?.lastFmReady; + const signedIn = !!serviceStatus?.signedIn; + return ( +
+ + {signedIn ? ( + + ) : ( + + )} + + {service === "LASTFM" && ( + + void toggleOwnKey(enabled)} disabled={!status || keychainBroken} /> + + )} + {service === "LASTFM" && status?.ownKeyEnabled && ( + + + + )} +
+ ); + })} +
+ + + + + + + + + + + + {waiting > 0 && ( + + + + )} + + + {signingIn && ( + signIn(signingIn, userName, secret)} + onClose={() => setSigningIn(null)} + /> + )} + {editingKey && ( + saveOwnKey(true, { apiKey, secret })} + onClose={() => setEditingKey(false)} + /> + )} +
+ ); +} diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index 6d32c089..b0f526b2 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -1085,6 +1085,50 @@ "settings_music_tray": "Show music controls in the tray", "settings_music_tray_desc": "Play, pause and skip songs from the menu bar or system tray, with the current song on top.", "settings_music_tray_unavailable": "Needs the libayatana-appindicator3 library and a desktop that shows tray icons.", + "scrobbling_title": "Scrobbling", + "scrobbling_summary": "Send what you listen to Last.fm, Libre.fm or ListenBrainz. Videos are never sent.", + "scrobbling_lastfm": "Last.fm", + "scrobbling_librefm": "Libre.fm", + "scrobbling_listenbrainz": "ListenBrainz", + "scrobbling_signed_in_as": "Signed in as %s", + "scrobbling_signed_in": "Signed in", + "scrobbling_not_signed_in": "Not signed in", + "scrobbling_needs_key": "This build has no Last.fm API key. Turn on Use your own API key to sign in.", + "scrobbling_sign_in": "Sign in", + "scrobbling_sign_out": "Sign out", + "scrobbling_sign_in_title": "Sign in to %s", + "scrobbling_user_name": "Username", + "scrobbling_password": "Password", + "scrobbling_password_note": "Your password is sent once and never stored.", + "scrobbling_token": "User token", + "scrobbling_token_note": "Copy it from listenbrainz.org/settings.", + "scrobbling_sign_in_failed": "Sign in failed", + "scrobbling_sign_in_offline": "Could not reach the service. Check your connection and try again.", + "scrobbling_keychain_unavailable": "Flow could not use the system keychain, so it cannot keep you signed in.", + "scrobbling_sign_out_confirm": "Sign out of %s? Listens waiting to be sent are dropped.", + "scrobbling_sign_out_failed": "Could not sign out", + "scrobbling_own_key": "Use your own API key", + "scrobbling_own_key_summary": "Changing it signs you out of Last.fm", + "scrobbling_own_key_values": "API key and secret", + "scrobbling_own_key_missing": "Not set", + "scrobbling_own_key_edit": "Edit", + "scrobbling_own_key_dialog_title": "Your Last.fm API key", + "scrobbling_own_key_dialog_body": "Create one for free at last.fm/api/account/create.", + "scrobbling_own_key_failed": "Could not save the API key", + "scrobbling_api_key": "API key", + "scrobbling_secret": "Shared secret", + "scrobbling_options": "Options", + "scrobbling_now_playing": "Show now playing", + "scrobbling_now_playing_summary": "Services see the song while it plays", + "scrobbling_send_likes": "Love liked songs", + "scrobbling_send_likes_summary": "Likes show as loved tracks on Last.fm and Libre.fm", + "scrobbling_deep_flow": "Scrobble during Deep Flow", + "scrobbling_deep_flow_summary": "Keep sending listens to Last.fm, Libre.fm and ListenBrainz", + "scrobbling_send_now": "Send waiting listens", + "scrobbling_waiting": "Waiting: %s", + "scrobbling_send_now_done": "All listens sent", + "scrobbling_send_now_waiting": "Some listens are still waiting. Flow keeps trying in the background.", + "scrobbling_status_failed": "Could not load your scrobbling accounts", "music_queue": "Queue", "music_lyrics": "Lyrics", "music_equalizer": "Equalizer", diff --git a/src/lib/useScrobbleSettings.test.ts b/src/lib/useScrobbleSettings.test.ts new file mode 100644 index 00000000..7c0dd565 --- /dev/null +++ b/src/lib/useScrobbleSettings.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { signInErrorMessage } from "./useScrobbleSettings"; +import { scrobbleTrack } from "./api/scrobble"; +import { getString } from "./i18n/index"; + +describe("scrobbling sign-in errors", () => { + it("shows the service's own words when it refused the sign-in", () => { + expect(signInErrorMessage({ kind: "scrobbleSignInRejected", message: "Invalid username or password" })) + .toBe("Invalid username or password"); + expect(signInErrorMessage({ kind: "scrobbleSignInRejected", message: " " })).toBe(getString("scrobbling_sign_in_failed")); + }); + + it("names each other failure", () => { + expect(signInErrorMessage({ kind: "scrobbleKeyMissing", message: "x" })).toBe(getString("scrobbling_needs_key")); + expect(signInErrorMessage({ kind: "network", message: "x" })).toBe(getString("scrobbling_sign_in_offline")); + expect(signInErrorMessage({ kind: "keychain", message: "x" })).toBe(getString("scrobbling_keychain_unavailable")); + expect(signInErrorMessage(new Error("boom"))).toBe(getString("scrobbling_sign_in_failed")); + }); +}); + +describe("scrobbleTrack", () => { + it("sends every artist name and the album, and lets the backend pick the first", () => { + const track = scrobbleTrack({ + id: "s", + title: "Song", + artists: [{ name: "Lead", id: null }, { name: "Guest", id: null }], + album: { name: "Record", id: "a" }, + duration: 200, + musicVideoType: null, + thumbnail: "", + explicit: false, + videoId: "s", + playlistId: null, + params: null, + }, 200_400.6); + expect(track).toEqual({ title: "Song", artists: ["Lead", "Guest"], album: "Record", durationMs: 200_401 }); + }); +}); diff --git a/src/lib/useScrobbleSettings.ts b/src/lib/useScrobbleSettings.ts new file mode 100644 index 00000000..8c407bc8 --- /dev/null +++ b/src/lib/useScrobbleSettings.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState } from "react"; + +import { normalizeBackendError } from "./api/errors"; +import { + flushScrobbles, + getScrobbleStatus, + scrobbleSignIn, + scrobbleSignOut, + setScrobbleOwnKey, + type ScrobbleService, + type ScrobbleStatus, +} from "./api/scrobble"; +import { getString } from "./i18n/index"; + +/** A sign-in failure as the dialog shows it: the service's own words when it gave any. */ +export function signInErrorMessage(error: unknown): string { + const { kind, message } = normalizeBackendError(error); + switch (kind) { + case "scrobbleSignInRejected": + return message.trim() || getString("scrobbling_sign_in_failed"); + case "scrobbleKeyMissing": + return getString("scrobbling_needs_key"); + case "network": + case "rateLimited": + return getString("scrobbling_sign_in_offline"); + case "keychain": + return getString("scrobbling_keychain_unavailable"); + default: + return getString("scrobbling_sign_in_failed"); + } +} + +export function useScrobbleSettings() { + const [status, setStatus] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + + const refresh = useCallback(async () => { + try { + setStatus(await getScrobbleStatus()); + setLoadFailed(false); + } catch { + setLoadFailed(true); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + /** Resolves to null on success, or the message to show. */ + const signIn = useCallback(async (service: ScrobbleService, userName: string, secret: string) => { + try { + await scrobbleSignIn(service, userName, secret); + await refresh(); + return null; + } catch (error) { + return signInErrorMessage(error); + } + }, [refresh]); + + const signOut = useCallback(async (service: ScrobbleService) => { + try { + await scrobbleSignOut(service); + return true; + } catch { + return false; + } finally { + await refresh(); + } + }, [refresh]); + + const saveOwnKey = useCallback(async (enabled: boolean, keys?: { apiKey: string; secret: string }) => { + try { + await setScrobbleOwnKey(enabled, keys); + return true; + } catch { + return false; + } finally { + await refresh(); + } + }, [refresh]); + + /** True when everything was sent, false when some listens are still waiting. */ + const sendNow = useCallback(async () => { + try { + return await flushScrobbles(); + } catch { + return false; + } finally { + await refresh(); + } + }, [refresh]); + + return { status, loadFailed, refresh, signIn, signOut, saveOwnKey, sendNow }; +} diff --git a/src/pages/ExtensionsPage.tsx b/src/pages/ExtensionsPage.tsx index 3ac057f8..50acd7e6 100644 --- a/src/pages/ExtensionsPage.tsx +++ b/src/pages/ExtensionsPage.tsx @@ -9,6 +9,7 @@ import { import { SettingsGroup } from "../components/settings/SettingsGroup"; import { SettingItem } from "../components/settings/SettingItem"; import { MusicTraySetting } from "../components/settings/MusicTraySetting"; +import { ScrobblingSection } from "../components/scrobbling/ScrobblingSection"; import { ToggleSwitch } from "../components/ui/ToggleSwitch"; import { Select } from "../components/ui/Select"; import { Button } from "../components/ui/Button"; @@ -250,6 +251,10 @@ export const ExtensionsPage: React.FC = () => { +
+ +
+ {sponsorBlockEnabled && (
From f2f8de598e88e3bfe9ea16405ede24adffba365f Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:57:41 +0300 Subject: [PATCH 6/6] docs(changelog): note scrobbling --- changelog/v0.1.0-beta3.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index 6f030fa6..54740d0a 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -46,6 +46,9 @@ MUSIC - A new "Resume long tracks" setting, off by default, starts mixes, podcasts and episodes of 10 minutes or more where you left them. - Music controls in the menu bar or system tray: the current song on top, then Play or Pause, Previous, Next and Show Flow. Turn it off under Integrations with Show music controls in the tray. - On Linux the tray is off by default and needs the libayatana-appindicator3 library, which the .deb, .rpm and AppImage now bring along. Without it the switch stays off instead of stopping Flow from starting. +- Scrobbling to Last.fm, Libre.fm and ListenBrainz, under Integrations. A song counts once you have heard half of it or four minutes, and songs of 30 seconds or less never count. Videos are never sent. +- Listens wait on your computer while you are offline and go out once you are back, up to 2,000 per service. Show now playing, Love liked songs and Scrobble during Deep Flow are options, and you can use your own Last.fm API key. +- Your Last.fm password is sent once and never stored. Sessions and tokens are kept in the system keychain, never in sync, backups or settings exports. AUDIO - Equalizer boosts no longer distort: the music equalizer turns the whole curve down by its highest boost before the filters, and a soft limiter bends any peak that still goes past full scale.