diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b164d97..5924aa7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,7 +127,13 @@ jobs: - name: Build native bundle working-directory: apps/desktop - run: npx tauri build --bundles ${{ matrix.bundle }} + env: + RETUNE_LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + RETUNE_LASTFM_SHARED_SECRET: ${{ secrets.LASTFM_API_SECRET }} + RETUNE_SUPPORT_EMAIL: ${{ vars.RETUNE_SUPPORT_EMAIL }} + run: | + node --input-type=module -e "for (const name of ['RETUNE_LASTFM_API_KEY', 'RETUNE_LASTFM_SHARED_SECRET']) if (!process.env[name]?.trim()) throw new Error(name + ' is required for release packaging')" + npx tauri build --bundles ${{ matrix.bundle }} - name: Configure stable macOS signing if: matrix.os == 'macos-15' diff --git a/.gitignore b/.gitignore index 0efa05c..bad7699 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target node_modules/ +.env.lastfm.local dist/ .DS_Store diff --git a/Cargo.lock b/Cargo.lock index f6b5a90..f88c40b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2819,6 +2819,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -4212,11 +4222,13 @@ dependencies = [ "base64 0.22.1", "chrono", "flate2", + "keyring", "librespot-audio", "librespot-core", "librespot-playback", "librespot-protocol", "log", + "md-5", "rand 0.9.5", "reqwest 0.12.28", "retune-audio", @@ -4231,6 +4243,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-window-state", "tempfile", "tokio", "url", @@ -5615,6 +5628,21 @@ dependencies = [ "zbus 5.18.0", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.13.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", +] + [[package]] name = "tauri-runtime" version = "2.11.3" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 48516d8..68998bb 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ souvlaki = "0.8" flate2 = "1" chrono = "0.4" log = "0.4" +md-5 = "0.10" librespot-audio = { version = "0.8", default-features = false } librespot-core = { version = "0.8", default-features = false, features = ["rustls-tls-native-roots"] } librespot-protocol = { version = "0.8", default-features = false } @@ -45,3 +46,15 @@ url = "2" [dev-dependencies] tempfile = "3" + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3", default-features = false, features = ["windows-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", default-features = false, features = ["sync-secret-service", "crypto-rust"] } + +[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] +tauri-plugin-window-state = "2" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index d860e1e..a0af4f2 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,3 +1,19 @@ fn main() { - tauri_build::build() + let windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + let attributes = if windows_msvc { + tauri_build::Attributes::new() + .windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest()) + } else { + tauri_build::Attributes::new() + }; + tauri_build::try_build(attributes).expect("failed to run Tauri build script"); + + if windows_msvc { + let manifest = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("windows-app-manifest.xml"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display()); + } } diff --git a/apps/desktop/src-tauri/src/diagnostics.rs b/apps/desktop/src-tauri/src/diagnostics.rs new file mode 100644 index 0000000..c39808c --- /dev/null +++ b/apps/desktop/src-tauri/src/diagnostics.rs @@ -0,0 +1,485 @@ +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +use serde::Serialize; +use tauri::Manager; +use tauri_plugin_opener::OpenerExt; + +pub(crate) const LOG_TARGET: &str = "retune::diagnostics"; +pub(crate) const SESSION_START_MARKER: &str = "retune.session.start"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub(crate) enum DiagnosticLevel { + Info, + Warn, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct DiagnosticEntry { + pub(crate) date: String, + pub(crate) time: String, + pub(crate) level: DiagnosticLevel, + pub(crate) target: String, + pub(crate) message: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiagnosticReport { + pub(crate) entries: Vec, + pub(crate) email_available: bool, +} + +fn bracketed_field(input: &str) -> Option<(&str, &str)> { + let input = input.strip_prefix('[')?; + let end = input.find(']')?; + Some((&input[..end], &input[end + 1..])) +} + +pub(crate) fn parse_line(line: &str) -> Option { + let (date, rest) = bracketed_field(line)?; + let (time, rest) = bracketed_field(rest)?; + let (level, rest) = bracketed_field(rest)?; + let (target, message) = bracketed_field(rest)?; + if date.is_empty() || time.is_empty() || target.is_empty() { + return None; + } + let message = message.strip_prefix(' ')?; + let level = match level { + "INFO" => DiagnosticLevel::Info, + "WARN" => DiagnosticLevel::Warn, + "ERROR" => DiagnosticLevel::Error, + _ => return None, + }; + Some(DiagnosticEntry { + date: date.to_owned(), + time: time.to_owned(), + level, + target: target.to_owned(), + message: redact_message(message), + }) +} + +const REDACTED: &str = "[REDACTED]"; +const SENSITIVE_KEYS: &[&str] = &[ + "access token", + "access_token", + "accesstoken", + "access-token", + "refresh token", + "refresh_token", + "refreshtoken", + "refresh-token", + "playback credentials", + "playback_credentials", + "playbackcredentials", + "playback-credentials", + "playback credential", + "playback-credential", + "authorization", + "authorization token", + "authorization_token", + "authorizationtoken", + "authorization-token", + "oauth_token", + "oauthtoken", + "oauth-token", + "auth token", + "auth_token", + "authtoken", + "auth-token", + "bearer", + "client secret", + "client_secret", + "clientsecret", + "client-secret", + "shared secret", + "shared_secret", + "sharedsecret", + "shared-secret", + "session key", + "session_key", + "sessionkey", + "session-key", + "lastfm session key", + "lastfm_session_key", + "lastfmsessionkey", + "lastfm-session-key", + "api key", + "api_key", + "apikey", + "api-key", + "api sig", + "api_sig", + "apisig", + "api-sig", + "sk", + "password", + "passwd", + "secret", + "token", + "key", +]; + +fn is_key_boundary(value: Option, key: &str) -> bool { + !value.is_some_and(|value| { + value.is_ascii_alphanumeric() || value == '_' || (value == '-' && !key.contains('-')) + }) +} + +fn next_sensitive_key(message: &str, from: usize) -> Option<(usize, usize)> { + let lower = message.to_ascii_lowercase(); + SENSITIVE_KEYS + .iter() + .filter_map(|key| { + let mut search = from; + while let Some(offset) = lower[search..].find(key) { + let start = search + offset; + let end = start + key.len(); + if is_key_boundary(message[..start].chars().next_back(), key) + && is_key_boundary(message[end..].chars().next(), key) + { + return Some((start, end)); + } + search = end; + } + None + }) + .min_by_key(|(start, _)| *start) +} + +fn redact_field_value( + message: &str, + key_start: usize, + key_end: usize, +) -> Option<(usize, usize, String)> { + let key = message[key_start..key_end].to_ascii_lowercase(); + let bytes = message.as_bytes(); + let mut cursor = key_end; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'"' | b'\'')) + { + cursor += 1; + } + if !matches!(bytes.get(cursor), Some(b':' | b'=')) { + return None; + } + cursor += 1; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cursor += 1; + } + let quoted = bytes + .get(cursor) + .copied() + .filter(|byte| matches!(byte, b'"' | b'\'')); + if let Some(quote) = quoted { + let start = cursor + 1; + let mut end = start; + while end < message.len() { + if bytes[end] == quote { + let mut backslashes = 0; + let mut previous = end; + while previous > start && bytes[previous - 1] == b'\\' { + backslashes += 1; + previous -= 1; + } + if backslashes % 2 == 0 { + break; + } + } + end += 1; + } + return Some((start, end, REDACTED.into())); + } + + let start = cursor; + let is_authorization = key.contains("authorization") || key == "bearer"; + let end = message[start..] + .char_indices() + .find_map(|(offset, value)| { + let delimiter = if is_authorization { + matches!(value, '&' | ',' | '}' | ']' | ';' | '\r' | '\n') + } else { + value.is_ascii_whitespace() + || matches!(value, '&' | ',' | '}' | ']' | ';' | '\r' | '\n') + }; + delimiter.then_some(start + offset) + }) + .unwrap_or(message.len()); + if start == end { + return None; + } + let value = &message[start..end]; + let replacement = value + .find(char::is_whitespace) + .map(|offset| &value[..offset]) + .filter(|scheme| { + scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("basic") + }) + .map(|scheme| format!("{scheme} {REDACTED}")) + .unwrap_or_else(|| REDACTED.into()); + Some((start, end, replacement)) +} + +fn redact_message(message: &str) -> String { + let mut result = String::with_capacity(message.len()); + let mut cursor = 0; + while let Some((key_start, key_end)) = next_sensitive_key(message, cursor) { + let Some((value_start, value_end, replacement)) = + redact_field_value(message, key_start, key_end) + else { + cursor = key_end; + continue; + }; + result.push_str(&message[cursor..value_start]); + result.push_str(&replacement); + cursor = value_end; + } + result.push_str(&message[cursor..]); + let lower = result.to_ascii_lowercase(); + lower + .contains(" returned http ") + .then(|| { + result + .split_once(": ") + .map(|(prefix, _)| format!("{prefix}: [redacted response body]")) + }) + .flatten() + .unwrap_or(result) +} + +fn redacted_mailto_url(email: &str, body: &str) -> String { + mailto_url(email, &redact_message(body)) +} + +pub(crate) fn current_session_entries(contents: &str) -> Vec { + let mut entries = Vec::new(); + let mut session_start = None; + for line in contents.lines() { + let Some(entry) = parse_line(line) else { + continue; + }; + if entry.target == LOG_TARGET && entry.message == SESSION_START_MARKER { + session_start = Some(entries.len()); + } else { + entries.push(entry); + } + } + let Some(session_start) = session_start else { + return Vec::new(); + }; + entries.into_iter().skip(session_start).collect() +} + +pub(crate) fn log_file_path(log_dir: &Path, app_name: &str) -> PathBuf { + log_dir.join(app_name).with_extension("log") +} + +pub(crate) fn read_current_session(path: &Path) -> io::Result> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + Ok(current_session_entries(&contents)) +} + +pub(crate) fn support_email_from(value: Option<&str>) -> Option<&str> { + value.and_then(|value| { + let value = value.trim(); + let (local, domain) = value.split_once('@')?; + (!local.is_empty() + && !domain.is_empty() + && !domain.contains('@') + && !value + .bytes() + .any(|byte| byte.is_ascii_whitespace() || matches!(byte, b'?' | b'#' | b'&'))) + .then_some(value) + }) +} + +fn support_email() -> Option<&'static str> { + support_email_from(option_env!("RETUNE_SUPPORT_EMAIL")) +} + +fn mailto_url(email: &str, body: &str) -> String { + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("subject", "Retune diagnostic report") + .append_pair("body", body) + .finish(); + format!("mailto:{email}?{query}") +} + +#[tauri::command] +pub(super) fn load_diagnostics(app: tauri::AppHandle) -> Result { + let log_dir = app + .path() + .app_log_dir() + .map_err(|error| format!("Could not locate the Retune log: {error}"))?; + let path = log_file_path(&log_dir, &app.package_info().name); + let entries = read_current_session(&path) + .map_err(|error| format!("Could not read the Retune log: {error}"))?; + Ok(DiagnosticReport { + entries, + email_available: support_email().is_some(), + }) +} + +#[tauri::command] +pub(super) fn email_diagnostics(app: tauri::AppHandle, body: String) -> Result<(), String> { + if body.trim().is_empty() { + return Err("There are no diagnostic problems to report.".into()); + } + let email = support_email().ok_or_else(|| { + "Email support is unavailable in this build. Copy Logs and share the report instead." + .to_string() + })?; + app.opener() + .open_url(redacted_mailto_url(email, &body), None::) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn parses_actual_bracketed_log_shape() { + for (name, expected) in [ + ("INFO", DiagnosticLevel::Info), + ("WARN", DiagnosticLevel::Warn), + ("ERROR", DiagnosticLevel::Error), + ] { + let entry = parse_line(&format!( + "[2026-08-16][14:03:02][{name}][retune::sync] retrying" + )) + .unwrap(); + assert_eq!(entry.date, "2026-08-16"); + assert_eq!(entry.time, "14:03:02"); + assert_eq!(entry.level, expected); + assert_eq!(entry.target, "retune::sync"); + assert_eq!(entry.message, "retrying"); + } + } + + #[test] + fn preserves_brackets_inside_messages() { + let entry = parse_line("[2026-08-16][14:03:02][ERROR][retune] failed [retry=2]").unwrap(); + assert_eq!(entry.message, "failed [retry=2]"); + } + + #[test] + fn redacts_all_credential_forms_while_preserving_context() { + let message = concat!( + "GET /play?access_token=access-canary&refresh_token=refresh-canary&access-token=access-hyphen-canary&refresh-token=refresh-hyphen-canary&sk=lastfm-query-canary ", + "headers={X-Api-Key: api-key-canary X-Client-Secret: client-secret-canary X-Session-Key: session-key-canary} ", + "headers={Authorization: Bearer authorization-canary} ", + "body={\"playbackCredentials\":\"playback-credential-canary\",", + "\"accessToken\":\"playback-canary\",", + "\"refreshToken\":\"refresh-body-canary\",\"session_key\":\"lastfm-canary\",", + "\"key\":\"session-key-canary\"}" + ); + let entry = parse_line(&format!( + "[2026-08-16][14:03:02][ERROR][retune::http] {message}" + )) + .unwrap(); + for secret in [ + "access-canary", + "refresh-canary", + "access-hyphen-canary", + "refresh-hyphen-canary", + "lastfm-query-canary", + "api-key-canary", + "client-secret-canary", + "session-key-canary", + "authorization-canary", + "playback-credential-canary", + "playback-canary", + "refresh-body-canary", + "lastfm-canary", + "session-key-canary", + ] { + assert!(!entry.message.contains(secret), "secret leaked: {secret}"); + } + assert!(entry.message.contains("GET /play")); + assert!(entry + .message + .contains("headers={Authorization: Bearer [REDACTED]}")); + assert!(entry.message.contains("body={")); + + let boundary = redact_message("access-tokenized=keep-access-tokenized"); + assert!(boundary.contains("keep-access-tokenized")); + + let url = redacted_mailto_url("support@example.com", message); + assert!(!url.contains("access-canary")); + assert!(!url.contains("lastfm-canary")); + + let entry = parse_line( + "[2026-08-16][14:03:02][ERROR][retune::spotify] Spotify /me returned HTTP 401: raw-canary", + ) + .unwrap(); + assert_eq!( + entry.message, + "Spotify /me returned HTTP 401: [redacted response body]" + ); + } + + #[test] + fn skips_malformed_and_unwanted_levels() { + assert!(parse_line("not a log").is_none()); + assert!(parse_line("[date][time][INFO][target]missing-space").is_none()); + assert!(parse_line("[date][time][DEBUG][target] debug").is_none()); + assert!(parse_line("[date][time][INFO][] empty target").is_none()); + } + + #[test] + fn selects_entries_after_the_latest_session_marker() { + let contents = format!( + "[date][time][INFO][retune] old\n[date][time][INFO][{LOG_TARGET}] {SESSION_START_MARKER}\n[date][time][INFO][retune] first\n[date][time][INFO][{LOG_TARGET}] {SESSION_START_MARKER}\n[date][time][WARN][retune] latest" + ); + let entries = current_session_entries(&contents); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].message, "latest"); + } + + #[test] + fn missing_log_is_empty_and_unreadable_log_is_an_error() { + let directory = tempdir().unwrap(); + assert!(read_current_session(&directory.path().join("missing.log")) + .unwrap() + .is_empty()); + assert!(read_current_session(directory.path()).is_err()); + } + + #[test] + fn support_email_helper_handles_configured_and_unconfigured_values() { + assert_eq!(support_email_from(None), None); + assert_eq!(support_email_from(Some(" ")), None); + assert_eq!(support_email_from(Some("not-an-email")), None); + assert_eq!( + support_email_from(Some("support@example.com?subject=bad")), + None + ); + assert_eq!( + support_email_from(Some(" support@example.com ")), + Some("support@example.com") + ); + } + + #[test] + fn email_url_encodes_subject_and_report_body() { + let url = mailto_url("support@example.com", "[ERROR] failed & retry"); + assert!(url.starts_with("mailto:support@example.com?")); + assert!(url.contains("subject=Retune+diagnostic+report")); + assert!(url.contains("body=%5BERROR%5D+failed+%26+retry")); + } +} diff --git a/apps/desktop/src-tauri/src/lastfm.rs b/apps/desktop/src-tauri/src/lastfm.rs new file mode 100644 index 0000000..464f155 --- /dev/null +++ b/apps/desktop/src-tauri/src/lastfm.rs @@ -0,0 +1,2340 @@ +use std::{ + collections::VecDeque, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +use md5::{Digest, Md5}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{Emitter, Manager}; +use tauri_plugin_opener::OpenerExt; +use tokio::sync::Mutex; +use url::Url; + +const API_URL: &str = "https://ws.audioscrobbler.com/2.0/"; +const AUTH_URL: &str = "https://www.last.fm/api/auth"; +const USER_AGENT: &str = concat!("Retune/", env!("CARGO_PKG_VERSION")); +#[cfg(not(test))] +pub(crate) const CREDENTIAL_SERVICE: &str = "com.rianjs.retune"; +#[cfg(not(test))] +pub(crate) const SESSION_ACCOUNT: &str = "lastfm-session"; +const RETRY_DELAYS: &[Duration] = &[ + Duration::from_secs(1), + Duration::from_secs(5), + Duration::from_secs(15), + Duration::from_secs(60), + Duration::from_secs(300), +]; + +#[derive(Clone)] +struct Credentials { + api_key: String, + shared_secret: String, +} + +fn credentials_from(api_key: Option<&str>, shared_secret: Option<&str>) -> Option { + let api_key = api_key?.trim(); + let shared_secret = shared_secret?.trim(); + (!api_key.is_empty() && !shared_secret.is_empty()).then(|| Credentials { + api_key: api_key.into(), + shared_secret: shared_secret.into(), + }) +} + +fn built_in_credentials() -> Option { + credentials_from( + option_env!("RETUNE_LASTFM_API_KEY"), + option_env!("RETUNE_LASTFM_SHARED_SECRET"), + ) +} + +#[derive(Clone, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LastFmSession { + username: String, + key: String, +} + +trait SessionStore: Send + Sync { + fn load(&self) -> Result, String>; + fn save(&self, session: &LastFmSession) -> Result<(), String>; + fn clear(&self) -> Result<(), String>; +} + +struct FileSessionStore { + path: PathBuf, +} + +impl FileSessionStore { + fn new(app_data_dir: impl AsRef) -> Self { + Self { + path: app_data_dir.as_ref().join("dev-lastfm-session.json"), + } + } +} + +impl SessionStore for FileSessionStore { + fn load(&self) -> Result, String> { + read_json(&self.path) + } + + fn save(&self, session: &LastFmSession) -> Result<(), String> { + write_secret_json(&self.path, session) + } + + fn clear(&self) -> Result<(), String> { + remove_file(&self.path) + } +} + +#[cfg(not(test))] +struct KeyringSessionStore { + entry: keyring::Entry, +} + +#[cfg(not(test))] +impl KeyringSessionStore { + fn new() -> Result { + keyring::Entry::new(CREDENTIAL_SERVICE, SESSION_ACCOUNT) + .map(|entry| Self { entry }) + .map_err(|_error| "Last.fm credential storage is unavailable.".to_string()) + } +} + +#[cfg(not(test))] +impl SessionStore for KeyringSessionStore { + fn load(&self) -> Result, String> { + match self.entry.get_password() { + Ok(value) => serde_json::from_str(&value) + .map(Some) + .map_err(|_| "Stored Last.fm session is invalid.".into()), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err("Last.fm credential storage is unavailable.".into()), + } + } + + fn save(&self, session: &LastFmSession) -> Result<(), String> { + let value = serde_json::to_string(session) + .map_err(|_| "Could not save the Last.fm session.".to_string())?; + self.entry + .set_password(&value) + .map_err(|_| "Could not save the Last.fm session.".into()) + } + + fn clear(&self) -> Result<(), String> { + match self.entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => Err("Could not remove the Last.fm session.".into()), + } + } +} + +#[derive(Clone)] +struct PendingTokenStore { + path: PathBuf, +} + +impl PendingTokenStore { + fn new(app_data_dir: impl AsRef) -> Self { + Self { + path: app_data_dir.as_ref().join("lastfm-pending-token.json"), + } + } + + fn load(&self) -> Result, String> { + read_json(&self.path) + } + + fn save(&self, token: &str) -> Result<(), String> { + write_secret_json(&self.path, &token) + } + + fn clear(&self) -> Result<(), String> { + remove_file(&self.path) + } +} + +#[cfg(test)] +struct SaveBlocker { + entered: std::sync::mpsc::Sender<()>, + release: std::sync::Mutex>, +} + +#[derive(Clone)] +struct QueueStore { + path: PathBuf, + #[cfg(test)] + blocker: Option>, +} + +impl QueueStore { + fn new(app_data_dir: impl AsRef) -> Self { + Self { + path: app_data_dir.as_ref().join("lastfm-scrobbles.json"), + #[cfg(test)] + blocker: None, + } + } + + fn load(&self) -> Result, String> { + Ok(read_json(&self.path)?.unwrap_or_default()) + } + + fn save(&self, queue: &VecDeque) -> Result<(), String> { + #[cfg(test)] + if let Some(blocker) = &self.blocker { + let _ = blocker.entered.send(()); + blocker + .release + .lock() + .expect("queue persistence blocker mutex is not poisoned") + .recv() + .expect("queue persistence blocker release is sent"); + } + let bytes = serde_json::to_vec(queue) + .map_err(|_| "Could not serialize the Last.fm scrobble queue.".to_string())?; + atomic_write(&self.path, &bytes, false) + } +} + +fn read_json(path: &Path) -> Result, String> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| format!("Could not read {}.", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(format!("Could not read {}.", path.display())), + } +} + +fn write_secret_json(path: &Path, value: &T) -> Result<(), String> { + let bytes = serde_json::to_vec(value) + .map_err(|_| "Could not serialize a Last.fm credential.".to_string())?; + atomic_write(path, &bytes, true) +} + +fn atomic_write(path: &Path, bytes: &[u8], secret: bool) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "Last.fm store path has no parent.".to_string())?; + fs::create_dir_all(parent) + .map_err(|_| "Could not create the Last.fm store directory.".to_string())?; + let temporary = path.with_extension(format!("tmp-{}", rand::random::())); + let result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if secret { + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|_| "Could not open the Last.fm temporary store.".to_string())?; + #[cfg(unix)] + if secret { + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| "Could not protect the Last.fm temporary store.".to_string())?; + } + file.write_all(bytes) + .map_err(|_| "Could not write the Last.fm store.".to_string())?; + file.sync_all() + .map_err(|_| "Could not sync the Last.fm store.".to_string())?; + fs::rename(&temporary, path).map_err(|_| "Could not replace the Last.fm store.".to_string()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result?; + #[cfg(unix)] + if secret { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|_| "Could not protect the Last.fm store.".to_string())?; + } + Ok(()) +} + +fn remove_file(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("Could not remove a Last.fm local store.".into()), + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct Scrobble { + artist: String, + track: String, + album: Option, + duration_secs: u64, + timestamp: u64, + #[serde(default)] + owner: String, +} + +impl Scrobble { + fn from_track(track: &super::playback::SnapshotTrack, timestamp: u64) -> Option { + let artist = track.art.trim(); + let title = track.name.trim(); + if artist.is_empty() || title.is_empty() || artist.eq_ignore_ascii_case("unknown artist") { + return None; + } + let album = (!track.alb.trim().is_empty() + && !track.alb.trim().eq_ignore_ascii_case("unknown album")) + .then(|| track.alb.trim().to_owned()); + Some(Self { + artist: artist.into(), + track: title.into(), + album, + duration_secs: track.duration_secs, + timestamp, + owner: String::new(), + }) + } + + fn now_playing_params(&self) -> Vec<(String, String)> { + let mut params = vec![ + ("artist".into(), self.artist.clone()), + ("track".into(), self.track.clone()), + ]; + if let Some(album) = &self.album { + params.push(("album".into(), album.clone())); + } + if self.duration_secs > 0 { + params.push(("duration".into(), self.duration_secs.to_string())); + } + params + } + + fn scrobble_params(&self, index: usize) -> Vec<(String, String)> { + let mut params = vec![ + (format!("artist[{index}]"), self.artist.clone()), + (format!("track[{index}]"), self.track.clone()), + (format!("timestamp[{index}]"), self.timestamp.to_string()), + ]; + if let Some(album) = &self.album { + params.push((format!("album[{index}]"), album.clone())); + } + if self.duration_secs > 0 { + params.push((format!("duration[{index}]"), self.duration_secs.to_string())); + } + params + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LastFmState { + pub available: bool, + pub connected: bool, + pub username: Option, + pub pending: bool, + pub reconnect_required: bool, + pub problem: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Failure { + Network, + Http(u16), + Api(u32), + Response, +} + +impl Failure { + fn code(self) -> Option { + match self { + Self::Api(code) => Some(code), + Self::Network | Self::Http(_) | Self::Response => None, + } + } +} + +struct Runtime { + enabled: bool, + session: Option, + pending: Option, + queue: VecDeque, + queue_owner: Option, + reconnect_required: bool, + build_problem: bool, + storage_problem: bool, + flushing: bool, + queue_revision: u64, +} + +#[derive(Default)] +struct ListeningState { + generation: Option, + track: Option, + started_at: u64, + played_ms: u64, + discontinuous: bool, + scrobbled: bool, +} + +enum ListeningAction { + NowPlaying(Scrobble), + Enqueue(Scrobble), +} + +impl ListeningState { + fn apply( + &mut self, + fact: super::playback::ListeningFact, + started_at: u64, + ) -> Vec { + match fact { + super::playback::ListeningFact::Started { generation, track } => { + self.generation = Some(generation); + self.track = Some(track.clone()); + self.started_at = started_at; + self.played_ms = 0; + self.discontinuous = false; + self.scrobbled = false; + Scrobble::from_track(&track, 0) + .map(ListeningAction::NowPlaying) + .into_iter() + .collect() + } + super::playback::ListeningFact::Forward { + generation, + track, + played_ms, + } => { + if !self.matches(generation, &track) { + return Vec::new(); + } + self.played_ms = self.played_ms.max(played_ms); + self.scrobble_if_eligible(&track, false) + } + super::playback::ListeningFact::Discontinuity { generation, track } => { + if self.matches(generation, &track) { + self.discontinuous = true; + } + Vec::new() + } + super::playback::ListeningFact::Completed { generation, track } => { + if !self.matches(generation, &track) { + return Vec::new(); + } + self.scrobble_if_eligible(&track, true) + } + } + } + + fn matches(&self, generation: u64, track: &super::playback::SnapshotTrack) -> bool { + self.generation == Some(generation) + && self + .track + .as_ref() + .is_some_and(|current| current.uri == track.uri) + } + + fn scrobble_if_eligible( + &mut self, + track: &super::playback::SnapshotTrack, + completed: bool, + ) -> Vec { + let threshold = self + .track + .as_ref() + .and_then(|track| scrobble_threshold_ms(track.duration_secs)); + let eligible = !self.scrobbled + && !self.discontinuous + && (threshold.is_some_and(|threshold| self.played_ms >= threshold) + || (completed && threshold.is_some())); + if !eligible { + return Vec::new(); + } + self.scrobbled = true; + Scrobble::from_track(track, self.started_at) + .map(ListeningAction::Enqueue) + .into_iter() + .collect() + } +} + +pub(crate) struct Service { + credentials: Option, + client: Client, + session_store: Arc, + pending_store: PendingTokenStore, + queue_store: QueueStore, + runtime: Mutex, + queue_io: Mutex<()>, + credential_io: Mutex<()>, + listening: std::sync::Mutex, + app: std::sync::Mutex>, +} + +impl Service { + pub(crate) fn new(app_data_dir: impl AsRef, dev_store: bool, enabled: bool) -> Arc { + let credentials = built_in_credentials(); + let (session_store, mut storage_problem): (Arc, bool) = if dev_store { + (Arc::new(FileSessionStore::new(&app_data_dir)), false) + } else { + #[cfg(not(test))] + { + match KeyringSessionStore::new() { + Ok(store) => (Arc::new(store), false), + Err(_) => (Arc::new(FailedSessionStore), true), + } + } + #[cfg(test)] + { + (Arc::new(FileSessionStore::new(&app_data_dir)), false) + } + }; + let pending_store = PendingTokenStore::new(&app_data_dir); + let queue_store = QueueStore::new(&app_data_dir); + let session = if credentials.is_some() { + match session_store.load() { + Ok(session) => session.filter(valid_session), + Err(error) => { + storage_problem = true; + log::error!( + "Last.fm local persistence failed while loading the session: {error}" + ); + None + } + } + } else { + None + }; + let pending = match pending_store.load() { + Ok(token) => token.filter(|token| !token.is_empty()), + Err(error) => { + log::error!( + "Last.fm local persistence failed while loading authorization state: {error}" + ); + None + } + }; + let mut queue = match queue_store.load() { + Ok(queue) => queue, + Err(error) => { + log::error!("Last.fm local persistence failed; queued scrobbles may be unavailable: {error}"); + VecDeque::new() + } + }; + let mut queue_owner = queue_owner(&queue).map(ToOwned::to_owned); + if let Some(session) = session.as_ref() { + if !queue.is_empty() && queue_owner.as_deref() != Some(session.username.as_str()) { + match queue_store.save(&VecDeque::new()) { + Ok(()) => { + queue.clear(); + queue_owner = None; + } + Err(error) => { + storage_problem = true; + log::error!( + "Last.fm local persistence failed while isolating queued account state: {error}" + ); + } + } + } + } + Arc::new(Self { + credentials, + client: Client::builder() + .timeout(Duration::from_secs(20)) + .user_agent(USER_AGENT) + .build() + .expect("Last.fm HTTP client configuration is valid"), + session_store, + pending_store, + queue_store, + runtime: Mutex::new(Runtime { + enabled, + session, + pending, + queue, + queue_owner, + reconnect_required: false, + build_problem: false, + storage_problem, + flushing: false, + queue_revision: 0, + }), + queue_io: Mutex::new(()), + credential_io: Mutex::new(()), + listening: std::sync::Mutex::new(ListeningState::default()), + app: std::sync::Mutex::new(None), + }) + } + + pub(crate) fn attach_app(&self, app: tauri::AppHandle) { + *self.app.lock().expect("Last.fm app mutex poisoned") = Some(app); + } + + async fn persist_queue(&self, queue: VecDeque) -> Result<(), String> { + let store = self.queue_store.clone(); + tauri::async_runtime::spawn_blocking(move || store.save(&queue)) + .await + .map_err(|_| "Last.fm queue persistence task stopped.".to_string())? + } + + async fn load_pending(&self) -> Result, String> { + let store = self.pending_store.clone(); + tauri::async_runtime::spawn_blocking(move || store.load()) + .await + .map_err(|_| "Last.fm pending-token task stopped.".to_string())? + } + + async fn save_pending(&self, token: String) -> Result<(), String> { + let store = self.pending_store.clone(); + tauri::async_runtime::spawn_blocking(move || store.save(&token)) + .await + .map_err(|_| "Last.fm pending-token task stopped.".to_string())? + } + + async fn clear_pending(&self) -> Result<(), String> { + let store = self.pending_store.clone(); + tauri::async_runtime::spawn_blocking(move || store.clear()) + .await + .map_err(|_| "Last.fm pending-token task stopped.".to_string())? + } + + async fn save_session(&self, session: LastFmSession) -> Result<(), String> { + let store = Arc::clone(&self.session_store); + tauri::async_runtime::spawn_blocking(move || store.save(&session)) + .await + .map_err(|_| "Last.fm session-store task stopped.".to_string())? + } + + async fn clear_session(&self) -> Result<(), String> { + let store = Arc::clone(&self.session_store); + tauri::async_runtime::spawn_blocking(move || store.clear()) + .await + .map_err(|_| "Last.fm session-store task stopped.".to_string())? + } + + pub(crate) async fn state(&self) -> LastFmState { + let runtime = self.runtime.lock().await; + let (available, problem) = if self.credentials.is_none() { + ( + false, + Some("Last.fm is unavailable in this build because its app credentials are not configured.".into()), + ) + } else if runtime.storage_problem { + ( + false, + Some( + "Last.fm is unavailable because secure credential storage could not be opened." + .into(), + ), + ) + } else if runtime.build_problem { + ( + false, + Some( + "This Retune build cannot use Last.fm because its app identity was rejected." + .into(), + ), + ) + } else if runtime.reconnect_required { + ( + true, + Some( + "Your Last.fm session expired. Reconnect Last.fm to resume scrobbling.".into(), + ), + ) + } else { + (true, None) + }; + LastFmState { + available, + connected: available && runtime.session.is_some(), + username: available + .then(|| { + runtime + .session + .as_ref() + .map(|session| session.username.clone()) + }) + .flatten(), + pending: available && runtime.pending.is_some(), + reconnect_required: available && runtime.reconnect_required, + problem, + } + } + + pub(crate) async fn set_enabled(self: &Arc, enabled: bool) { + let should_flush = { + let mut runtime = self.runtime.lock().await; + runtime.enabled = enabled; + flush_ready(&runtime) + }; + if should_flush { + Arc::clone(self).schedule_flush().await; + } + } + + pub(crate) async fn connect( + self: &Arc, + app: &tauri::AppHandle, + ) -> Result { + self.ensure_available().await?; + let payload = match self.post("auth.getToken", vec![], None).await { + Ok(payload) => payload, + Err(error) => return Err(self.handle_failure(error).await), + }; + let token = response_text(&payload, &["token"]) + .filter(|token| !token.is_empty()) + .ok_or_else(|| "Last.fm did not return an authorization token.".to_string())?; + let _credential_io = self.credential_io.lock().await; + self.save_pending(token.clone()).await?; + { + let mut runtime = self.runtime.lock().await; + runtime.pending = Some(token.clone()); + runtime.reconnect_required = false; + } + let mut url = Url::parse(AUTH_URL).map_err(|_| "Last.fm authorization URL is invalid.")?; + url.query_pairs_mut() + .append_pair( + "api_key", + self.credentials + .as_ref() + .expect("checked above") + .api_key + .as_str(), + ) + .append_pair("token", &token); + app.opener() + .open_url(url.to_string(), None::) + .map_err(|_| "Could not open Last.fm in the system browser.")?; + Ok(self.state().await) + } + + pub(crate) async fn finish( + self: &Arc, + _app: &tauri::AppHandle, + ) -> Result { + self.ensure_available().await?; + let token = { + let runtime = self.runtime.lock().await; + runtime.pending.clone() + } + .or(self.load_pending().await?) + .filter(|token| !token.is_empty()) + .ok_or_else(|| "Start Last.fm authorization first.".to_string())?; + let payload = match self + .post("auth.getSession", vec![("token".into(), token)], None) + .await + { + Ok(payload) => payload, + Err(error) => return Err(self.handle_failure(error).await), + }; + let session = LastFmSession { + username: response_text(&payload, &["session", "name"]) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Last.fm did not return a username.".to_string())?, + key: response_text(&payload, &["session", "key"]) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Last.fm did not return a session key.".to_string())?, + }; + let _credential_io = self.credential_io.lock().await; + self.commit_session(session).await?; + log::info!("Last.fm connected"); + self.emit_state().await; + Arc::clone(self).schedule_flush().await; + Ok(self.state().await) + } + + async fn commit_session(&self, session: LastFmSession) -> Result<(), String> { + let previous_session = self.runtime.lock().await.session.clone(); + self.save_session(session.clone()).await?; + if let Err(error) = self.reconcile_queue_owner(&session.username).await { + let rollback = match previous_session { + Some(previous) => self.save_session(previous).await, + None => self.clear_session().await, + }; + if let Err(rollback_error) = rollback { + self.runtime.lock().await.storage_problem = true; + return Err(format!("{error} {rollback_error}")); + } + return Err(error); + } + let mut runtime = self.runtime.lock().await; + runtime.session = Some(session.clone()); + runtime.pending = None; + runtime.reconnect_required = false; + runtime.build_problem = false; + runtime.queue_owner = Some(session.username); + drop(runtime); + if let Err(error) = self.clear_pending().await { + log::error!( + "Last.fm local persistence failed while clearing completed authorization: {error}" + ); + } + Ok(()) + } + + pub(crate) async fn disconnect(&self) -> Result { + let empty = VecDeque::new(); + let _credential_io = self.credential_io.lock().await; + { + let _queue_io = self.queue_io.lock().await; + let (previous, revision) = { + let mut runtime = self.runtime.lock().await; + let previous = runtime.queue.clone(); + runtime.queue.clear(); + runtime.queue_owner = None; + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + (previous, runtime.queue_revision) + }; + if let Err(error) = self.persist_queue(empty.clone()).await { + let mut runtime = self.runtime.lock().await; + if runtime.queue_revision == revision { + runtime.queue = previous; + runtime.queue_owner = queue_owner(&runtime.queue).map(ToOwned::to_owned); + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + } + log::error!( + "Last.fm local persistence failed while disconnecting; session retained" + ); + drop(runtime); + self.emit_state().await; + return Err(error); + } + } + + let session_error = self.clear_session().await.err(); + let pending_error = self.clear_pending().await.err(); + let mut runtime = self.runtime.lock().await; + if session_error.is_none() { + runtime.session = None; + runtime.reconnect_required = false; + runtime.build_problem = false; + } + if pending_error.is_none() { + runtime.pending = None; + } + let errors = session_error + .into_iter() + .chain(pending_error) + .collect::>(); + drop(runtime); + if !errors.is_empty() { + log::error!("Last.fm local persistence failed while disconnecting"); + self.emit_state().await; + return Err(errors.join(" ")); + } + log::info!("Last.fm disconnected"); + self.emit_state().await; + Ok(self.state().await) + } + + pub(crate) fn handle_listening_fact(self: &Arc, fact: super::playback::ListeningFact) { + let actions = self + .listening + .lock() + .expect("Last.fm listening mutex poisoned") + .apply(fact, crate::unix_now()); + for action in actions { + match action { + ListeningAction::NowPlaying(scrobble) => { + let service = Arc::clone(self); + tauri::async_runtime::spawn(async move { + service.send_now_playing(scrobble).await; + }); + } + ListeningAction::Enqueue(scrobble) => { + let service = Arc::clone(self); + tauri::async_runtime::spawn(async move { + log::debug!("Last.fm scrobble eligible"); + service.enqueue(scrobble).await; + }); + } + } + } + } + + async fn ensure_available(&self) -> Result<(), String> { + let state = self.state().await; + if state.available { + Ok(()) + } else { + Err(state + .problem + .unwrap_or_else(|| "Last.fm is unavailable in this build.".into())) + } + } + + async fn send_now_playing(&self, scrobble: Scrobble) { + let session = { + let runtime = self.runtime.lock().await; + if !runtime.enabled + || runtime.session.is_none() + || runtime.build_problem + || runtime.storage_problem + { + return; + } + runtime.session.clone().expect("checked above") + }; + let result = self + .post( + "track.updateNowPlaying", + scrobble.now_playing_params(), + Some(&session.key), + ) + .await; + match result { + Ok(_) => log::debug!("Last.fm Now Playing accepted"), + Err(Failure::Api(9)) => { + if self.invalidate_session(&session).await { + log::info!("Last.fm reconnect required"); + log::warn!("Last.fm session expired; reconnect required"); + self.emit_state().await; + } + } + Err(error) if is_build_failure(error) => { + let mut runtime = self.runtime.lock().await; + if runtime.session.as_ref() != Some(&session) { + return; + } + runtime.build_problem = true; + log_build_failure(error); + drop(runtime); + self.emit_state().await; + } + Err(_) => log::warn!("Last.fm Now Playing request failed; it will not be retried"), + } + } + + async fn enqueue(self: &Arc, mut scrobble: Scrobble) { + let _queue_io = self.queue_io.lock().await; + let (queue, previous, revision) = { + let mut runtime = self.runtime.lock().await; + if !runtime.enabled + || runtime.build_problem + || runtime.storage_problem + || (runtime.session.is_none() && !runtime.reconnect_required) + { + return; + } + let Some(owner) = runtime + .session + .as_ref() + .map(|session| session.username.clone()) + .or_else(|| runtime.queue_owner.clone()) + else { + return; + }; + if !runtime.queue.is_empty() && queue_owner(&runtime.queue) != Some(owner.as_str()) { + return; + } + scrobble.owner = owner.clone(); + let previous = runtime.queue.clone(); + runtime.queue.push_back(scrobble); + runtime.queue_owner = Some(owner); + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + (runtime.queue.clone(), previous, runtime.queue_revision) + }; + if let Err(error) = self.persist_queue(queue.clone()).await { + let mut runtime = self.runtime.lock().await; + if runtime.queue_revision == revision { + runtime.queue = previous; + runtime.queue_owner = queue_owner(&runtime.queue).map(ToOwned::to_owned); + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + } + log::error!("Last.fm local persistence failed; queued scrobble may be lost: {error}"); + return; + } + drop(_queue_io); + log::debug!("Last.fm scrobble eligible; queue count={}", queue.len()); + let should_flush = { + let runtime = self.runtime.lock().await; + flush_ready(&runtime) + }; + if should_flush { + Arc::clone(self).schedule_flush().await; + } + } + + async fn schedule_flush(self: Arc) { + let should_spawn = { + let mut runtime = self.runtime.lock().await; + if !flush_ready(&runtime) || runtime.flushing { + false + } else { + runtime.flushing = true; + true + } + }; + if !should_spawn { + return; + } + let service = Arc::clone(&self); + tauri::async_runtime::spawn(async move { service.flush_loop().await }); + } + + async fn flush_loop(self: Arc) { + let mut attempt = 0; + loop { + let mut stopped = false; + loop { + match self.flush_once().await { + FlushOutcome::Done => break, + FlushOutcome::Continue => { + attempt = 0; + continue; + } + FlushOutcome::Retry => { + let ready = { + let runtime = self.runtime.lock().await; + flush_ready(&runtime) + }; + if !ready { + break; + } + let delay = retry_delay(attempt); + attempt = attempt.saturating_add(1); + log::debug!("Last.fm retry scheduled in {}s", delay.as_secs()); + tokio::time::sleep(delay).await; + } + FlushOutcome::Stop => { + stopped = true; + break; + } + } + } + let should_restart = { + let mut runtime = self.runtime.lock().await; + runtime.flushing = false; + flush_ready(&runtime) + }; + if !should_restart || stopped { + break; + } + let claimed_restart = { + let mut runtime = self.runtime.lock().await; + if flush_ready(&runtime) && !runtime.flushing { + runtime.flushing = true; + true + } else { + false + } + }; + if !claimed_restart { + break; + } + attempt = 0; + } + } + + async fn flush_once(&self) -> FlushOutcome { + let (batch, session) = { + let runtime = self.runtime.lock().await; + if !flush_ready(&runtime) { + return FlushOutcome::Done; + } + let batch = next_batch(&runtime.queue); + let session = runtime.session.clone().expect("checked above"); + (batch, session) + }; + let count = batch.len(); + log::debug!("Last.fm scrobble batch count={count}"); + let params = batch + .iter() + .enumerate() + .flat_map(|(index, item)| item.scrobble_params(index)) + .collect(); + match self + .post("track.scrobble", params, Some(&session.key)) + .await + { + Ok(payload) => { + let Some(codes) = scrobble_codes(&payload, count) else { + log::warn!( + "Last.fm returned an unusable scrobble response; preserving the queue" + ); + return FlushOutcome::Retry; + }; + let _queue_io = self.queue_io.lock().await; + let (original, queue, removed, revision) = { + let mut runtime = self.runtime.lock().await; + if !queue_starts_with(&runtime.queue, &batch) + || runtime.session.as_ref() != Some(&session) + { + return FlushOutcome::Done; + } + let original = runtime.queue.clone(); + let removed = apply_scrobble_results(&mut runtime.queue, &batch, &codes); + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + ( + original, + runtime.queue.clone(), + removed, + runtime.queue_revision, + ) + }; + if let Err(error) = self.persist_queue(queue.clone()).await { + let mut runtime = self.runtime.lock().await; + if runtime.queue_revision == revision { + runtime.queue = original; + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + } + log::error!("Last.fm local persistence failed after scrobble response; queue retained: {error}"); + return FlushOutcome::Stop; + } + for (item, code) in removed { + if code == 0 { + log::info!( + "Last.fm scrobble accepted: {} - {}", + item.artist, + item.track + ); + } else { + log::info!( + "Last.fm scrobble ignored (code {code}): {} - {}", + item.artist, + item.track + ); + } + } + if queue.is_empty() { + FlushOutcome::Done + } else { + FlushOutcome::Continue + } + } + Err(Failure::Api(9)) => { + if self.invalidate_session(&session).await { + log::info!("Last.fm reconnect required"); + log::warn!("Last.fm session expired; reconnect required"); + self.emit_state().await; + } + FlushOutcome::Stop + } + Err(error) if is_build_failure(error) => { + let mut runtime = self.runtime.lock().await; + if runtime.session.as_ref() != Some(&session) { + return FlushOutcome::Done; + } + runtime.build_problem = true; + log_build_failure(error); + drop(runtime); + self.emit_state().await; + FlushOutcome::Stop + } + Err(error) if is_retryable(error) => { + log::warn!("Last.fm service or network request failed; queued scrobbles retained"); + FlushOutcome::Retry + } + Err(error) => { + let _queue_io = self.queue_io.lock().await; + let (original, queue, removed, revision) = { + let mut runtime = self.runtime.lock().await; + if !queue_starts_with(&runtime.queue, &batch) + || runtime.session.as_ref() != Some(&session) + { + return FlushOutcome::Done; + } + let original = runtime.queue.clone(); + let removed = batch + .iter() + .filter_map(|item| runtime.queue.pop_front().map(|_| item.clone())) + .collect::>(); + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + ( + original, + runtime.queue.clone(), + removed, + runtime.queue_revision, + ) + }; + if let Err(save_error) = self.persist_queue(queue.clone()).await { + let mut runtime = self.runtime.lock().await; + if runtime.queue_revision == revision { + runtime.queue = original; + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + } + log::error!("Last.fm local persistence failed after permanent rejection; queue retained: {save_error}"); + return FlushOutcome::Stop; + } + for item in removed { + log::warn!( + "Last.fm scrobble permanently rejected (code {:?}): {} - {}", + error.code(), + item.artist, + item.track + ); + } + if queue.is_empty() { + FlushOutcome::Done + } else { + FlushOutcome::Continue + } + } + } + } + + async fn invalidate_session(&self, expected: &LastFmSession) -> bool { + let _credential_io = self.credential_io.lock().await; + let expected = expected.clone(); + let current = self.runtime.lock().await.session.clone(); + if current.as_ref() != Some(&expected) { + return false; + } + let clear_error = self.clear_session().await.err(); + let mut runtime = self.runtime.lock().await; + if runtime.session.as_ref() != Some(&expected) { + return false; + } + if runtime.queue_owner.is_none() { + runtime.queue_owner = Some(expected.username.clone()); + } + runtime.session = None; + runtime.reconnect_required = true; + if let Some(error) = clear_error { + log::error!( + "Last.fm local persistence failed while clearing an invalid session: {error}" + ); + } + true + } + + async fn post( + &self, + method: &str, + mut params: Vec<(String, String)>, + session_key: Option<&str>, + ) -> Result { + let credentials = self.credentials.as_ref().ok_or(Failure::Api(10))?; + params.push(("api_key".into(), credentials.api_key.clone())); + params.push(("method".into(), method.into())); + if let Some(session_key) = session_key { + params.push(("sk".into(), session_key.into())); + } + params.push(("format".into(), "json".into())); + let api_sig = signature(¶ms, &credentials.shared_secret); + params.push(("api_sig".into(), api_sig)); + let response = self + .client + .post(API_URL) + .form(¶ms) + .send() + .await + .map_err(|_| Failure::Network)?; + let status = response.status().as_u16(); + let body = response.bytes().await.map_err(|_| Failure::Network)?; + let value: Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) if !(200..300).contains(&status) => return Err(Failure::Http(status)), + Err(_) => return Err(Failure::Response), + }; + if let Some(code) = error_code(&value) { + return Err(Failure::Api(code)); + } + if !(200..300).contains(&status) { + return Err(Failure::Http(status)); + } + if let Some(status) = response_text(&value, &["status"]) + .or_else(|| response_text(&value, &["@status"])) + .or_else(|| response_text(&value, &["@attr", "status"])) + { + if status != "ok" { + return Err(Failure::Response); + } + } + Ok(value) + } + + async fn handle_failure(&self, failure: Failure) -> String { + match failure { + Failure::Api(10 | 13 | 26) => { + let mut runtime = self.runtime.lock().await; + runtime.build_problem = true; + drop(runtime); + log_build_failure(failure); + self.emit_state().await; + "This Retune build cannot use Last.fm because its app identity was rejected.".into() + } + Failure::Api(11 | 16 | 29) | Failure::Network | Failure::Http(500..=599) => { + log::warn!("Last.fm temporary service or network error"); + "Last.fm is temporarily unavailable. Try again later.".into() + } + Failure::Api(9) => { + let session = self.runtime.lock().await.session.clone(); + if let Some(session) = session { + self.invalidate_session(&session).await; + } + log::info!("Last.fm reconnect required"); + log::warn!("Last.fm session expired; reconnect required"); + self.emit_state().await; + "Your Last.fm session expired. Reconnect Last.fm to resume scrobbling.".into() + } + failure @ (Failure::Api(_) | Failure::Http(_) | Failure::Response) => { + log::warn!( + "Last.fm request permanently rejected (code {:?})", + failure.code() + ); + "Last.fm could not complete that request.".into() + } + } + } + + async fn reconcile_queue_owner(&self, username: &str) -> Result<(), String> { + let _queue_io = self.queue_io.lock().await; + let (revision, should_clear) = { + let runtime = self.runtime.lock().await; + ( + runtime.queue_revision, + !runtime.queue.is_empty() && queue_owner(&runtime.queue) != Some(username), + ) + }; + if should_clear { + self.persist_queue(VecDeque::new()).await?; + let mut runtime = self.runtime.lock().await; + if runtime.queue_revision == revision { + runtime.queue.clear(); + runtime.queue_owner = None; + runtime.queue_revision = runtime.queue_revision.wrapping_add(1); + } + } + self.runtime.lock().await.queue_owner = Some(username.to_owned()); + Ok(()) + } + + async fn emit_state(&self) { + let app = self.app.lock().expect("Last.fm app mutex poisoned").clone(); + if let Some(app) = app { + let _ = app.emit("lastfm-changed", self.state().await); + } + } +} + +#[cfg(not(test))] +struct FailedSessionStore; + +#[cfg(not(test))] +impl SessionStore for FailedSessionStore { + fn load(&self) -> Result, String> { + Err("Last.fm credential storage is unavailable.".into()) + } + + fn save(&self, _session: &LastFmSession) -> Result<(), String> { + Err("Last.fm credential storage is unavailable.".into()) + } + + fn clear(&self) -> Result<(), String> { + Err("Last.fm credential storage is unavailable.".into()) + } +} + +enum FlushOutcome { + Done, + Continue, + Retry, + Stop, +} + +fn flush_ready(runtime: &Runtime) -> bool { + runtime.enabled + && runtime + .session + .as_ref() + .is_some_and(|session| queue_owner(&runtime.queue) == Some(session.username.as_str())) + && !runtime.queue.is_empty() + && !runtime.build_problem + && !runtime.storage_problem +} + +fn queue_owner(queue: &VecDeque) -> Option<&str> { + let owner = queue.front()?.owner.as_str(); + (!owner.is_empty() && queue.iter().all(|item| item.owner.as_str() == owner)).then_some(owner) +} + +fn next_batch(queue: &VecDeque) -> Vec { + queue.iter().take(50).cloned().collect() +} + +fn queue_starts_with(queue: &VecDeque, batch: &[Scrobble]) -> bool { + queue.len() >= batch.len() && queue.iter().zip(batch).all(|(queued, item)| queued == item) +} + +fn apply_scrobble_results( + queue: &mut VecDeque, + batch: &[Scrobble], + codes: &[u32], +) -> Vec<(Scrobble, u32)> { + let mut removed = Vec::with_capacity(batch.len()); + for (item, code) in batch.iter().zip(codes) { + if queue.pop_front().is_none() { + break; + } + removed.push((item.clone(), *code)); + } + removed +} + +fn valid_session(session: &LastFmSession) -> bool { + !session.username.trim().is_empty() && !session.key.trim().is_empty() +} + +fn signature(params: &[(String, String)], shared_secret: &str) -> String { + let mut values = params + .iter() + .filter(|(key, _)| key != "format" && key != "callback" && key != "api_sig") + .collect::>(); + values.sort_by(|left, right| left.0.cmp(&right.0)); + let mut input = String::new(); + for (key, value) in values { + input.push_str(key); + input.push_str(value); + } + input.push_str(shared_secret); + let digest = Md5::digest(input.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn response_text(value: &Value, path: &[&str]) -> Option { + let mut current = value.get("lfm").unwrap_or(value); + for key in path { + current = current.get(*key)?; + } + current.as_str().map(ToOwned::to_owned).or_else(|| { + current + .get("#text") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) +} + +fn error_code(value: &Value) -> Option { + let root = value.get("lfm").unwrap_or(value); + let error = root.get("error")?; + let code = error + .get("code") + .or_else(|| error.get("@code")) + .unwrap_or(error); + code.as_u64() + .or_else(|| code.as_str()?.parse().ok()) + .map(|code| code as u32) +} + +fn scrobble_codes(value: &Value, expected: usize) -> Option> { + let root = value.get("lfm").unwrap_or(value); + let scrobbles = root.get("scrobbles")?; + let items = scrobbles.get("scrobble")?; + let items = match items { + Value::Array(items) => items.clone(), + Value::Object(_) => vec![items.clone()], + _ => return None, + }; + if items.len() != expected { + return None; + } + Some( + items + .into_iter() + .map(|item| { + item.get("ignoredMessage") + .and_then(|message| message.get("code").or_else(|| message.get("@code"))) + .and_then(|code| code.as_u64().or_else(|| code.as_str()?.parse().ok())) + .unwrap_or(0) as u32 + }) + .collect(), + ) +} + +fn is_build_failure(failure: Failure) -> bool { + matches!(failure, Failure::Api(10 | 13 | 26)) +} + +fn log_build_failure(failure: Failure) { + match failure { + Failure::Api(26) => { + log::warn!("Last.fm API key is suspended; automatic scrobbling is paused") + } + Failure::Api(10 | 13) => { + log::warn!("Last.fm app identity was rejected; automatic scrobbling is paused") + } + _ => {} + } +} + +fn is_retryable(failure: Failure) -> bool { + matches!( + failure, + Failure::Network + | Failure::Response + | Failure::Http(500..=599) + | Failure::Api(11 | 16 | 29) + ) +} + +fn retry_delay(attempt: usize) -> Duration { + RETRY_DELAYS[attempt.min(RETRY_DELAYS.len() - 1)] +} + +pub(crate) fn scrobble_threshold_ms(duration_secs: u64) -> Option { + (duration_secs > 30).then(|| (duration_secs.saturating_mul(500)).min(240_000)) +} + +#[tauri::command] +pub(crate) async fn lastfm_state( + state: tauri::State<'_, crate::AppState>, +) -> Result { + Ok(state.lastfm.state().await) +} + +#[tauri::command] +pub(crate) async fn connect_lastfm(app: tauri::AppHandle) -> Result { + app.state::().lastfm.connect(&app).await +} + +#[tauri::command] +pub(crate) async fn finish_lastfm(app: tauri::AppHandle) -> Result { + let state = app.state::(); + let result = state.lastfm.finish(&app).await?; + state.lastfm.set_enabled(true).await; + crate::set_lastfm_scrobbling(&app, true)?; + Ok(result) +} + +#[tauri::command] +pub(crate) async fn disconnect_lastfm(app: tauri::AppHandle) -> Result { + app.state::().lastfm.disconnect().await +} + +#[cfg(test)] +mod tests { + use std::fs; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + use super::*; + use crate::playback::SnapshotTrack; + + fn track(artist: &str, album: &str) -> SnapshotTrack { + SnapshotTrack { + id: 1, + uri: "file:///tmp/song.mp3".into(), + name: "Song".into(), + art: artist.into(), + alb: album.into(), + duration_secs: 180, + } + } + + fn queued_scrobble(timestamp: u64) -> Scrobble { + Scrobble { + artist: "Artist".into(), + track: "Song".into(), + album: None, + duration_secs: 180, + timestamp, + owner: "user".into(), + } + } + + struct FailingClearSessionStore; + + impl SessionStore for FailingClearSessionStore { + fn load(&self) -> Result, String> { + Ok(None) + } + + fn save(&self, _session: &LastFmSession) -> Result<(), String> { + Ok(()) + } + + fn clear(&self) -> Result<(), String> { + Err("session clear failed".into()) + } + } + + struct FailingSaveSessionStore { + session: LastFmSession, + } + + impl SessionStore for FailingSaveSessionStore { + fn load(&self) -> Result, String> { + Ok(Some(self.session.clone())) + } + + fn save(&self, _session: &LastFmSession) -> Result<(), String> { + Err("session save failed".into()) + } + + fn clear(&self) -> Result<(), String> { + Ok(()) + } + } + + #[test] + fn empty_or_missing_built_in_credentials_disable_integration() { + assert!(credentials_from(None, Some("secret")).is_none()); + assert!(credentials_from(Some(""), Some("secret")).is_none()); + assert!(credentials_from(Some("key"), Some(" ")).is_none()); + assert!(credentials_from(Some(" key "), Some(" secret ")).is_some()); + } + + #[test] + fn dev_session_store_round_trips_with_owner_only_permissions() { + let directory = tempfile::tempdir().unwrap(); + let store = FileSessionStore::new(directory.path()); + let session = LastFmSession { + username: "user".into(), + key: "session".into(), + }; + store.save(&session).unwrap(); + let loaded = store.load().unwrap(); + assert!(loaded + .as_ref() + .is_some_and(|value| value.username == session.username && value.key == session.key)); + #[cfg(unix)] + assert_eq!( + fs::metadata(directory.path().join("dev-lastfm-session.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + store.clear().unwrap(); + assert!(store.load().unwrap().is_none()); + } + + #[test] + fn signature_sorts_parameters_and_excludes_format() { + let params = vec![ + ("token".into(), "YOUR_REQUESTED_TOKEN".into()), + ("format".into(), "json".into()), + ("method".into(), "auth.getSession".into()), + ("api_key".into(), "YOUR_API_KEY".into()), + ]; + assert_eq!( + signature(¶ms, "YOUR_SECRET"), + "94539006de89b3c6b3c030bb1e52b9c4" + ); + } + + #[test] + fn signature_uses_ascii_order_for_array_parameters() { + let params = vec![ + ("artist[1]".into(), "one".into()), + ("artist[10]".into(), "ten".into()), + ]; + assert_eq!( + signature(¶ms, "SECRET"), + "5529e8d265523c2b48a5183272fcac8b" + ); + } + + #[test] + fn metadata_filters_unknown_artist_and_album() { + assert!(Scrobble::from_track(&track("Unknown Artist", "Album"), 1).is_none()); + let scrobble = Scrobble::from_track(&track("Artist", "Unknown Album"), 1).unwrap(); + assert_eq!(scrobble.album, None); + assert_eq!(scrobble.artist, "Artist"); + assert_eq!(scrobble.track, "Song"); + assert_eq!(scrobble.duration_secs, 180); + assert_eq!( + scrobble + .scrobble_params(0) + .into_iter() + .map(|(key, _)| key) + .collect::>(), + vec![ + "artist[0]".to_string(), + "track[0]".to_string(), + "timestamp[0]".to_string(), + "duration[0]".to_string(), + ] + ); + assert!(Scrobble::from_track(&track(" ", "Album"), 1).is_none()); + } + + #[test] + fn threshold_matches_last_fm_boundaries() { + assert_eq!(scrobble_threshold_ms(30), None); + assert_eq!(scrobble_threshold_ms(31), Some(15_500)); + assert_eq!(scrobble_threshold_ms(60), Some(30_000)); + assert_eq!(scrobble_threshold_ms(180), Some(90_000)); + assert_eq!(scrobble_threshold_ms(480), Some(240_000)); + assert_eq!(scrobble_threshold_ms(1200), Some(240_000)); + } + + #[test] + fn listening_provider_eligibility_keeps_the_30_001ms_boundary() { + let track = SnapshotTrack { + duration_secs: 31, + ..track("Artist", "Album") + }; + let mut state = ListeningState::default(); + assert!(matches!( + state + .apply( + crate::playback::ListeningFact::Started { + generation: 1, + track: track.clone(), + }, + 1234, + ) + .as_slice(), + [ListeningAction::NowPlaying(scrobble)] if scrobble.timestamp == 0 + )); + assert!(state + .apply( + crate::playback::ListeningFact::Forward { + generation: 1, + track: track.clone(), + played_ms: 15_000, + }, + 0, + ) + .is_empty()); + assert!(matches!( + state + .apply( + crate::playback::ListeningFact::Forward { + generation: 1, + track, + played_ms: 16_000, + }, + 0, + ) + .as_slice(), + [ListeningAction::Enqueue(scrobble)] if scrobble.timestamp == 1234 + )); + } + + #[test] + fn listening_provider_rejects_stale_and_discontinuous_progress() { + let track = track("Artist", "Album"); + let mut state = ListeningState::default(); + state.apply( + crate::playback::ListeningFact::Started { + generation: 1, + track: track.clone(), + }, + 1234, + ); + assert!(state + .apply( + crate::playback::ListeningFact::Forward { + generation: 2, + track: track.clone(), + played_ms: 100_000, + }, + 0, + ) + .is_empty()); + state.apply( + crate::playback::ListeningFact::Discontinuity { + generation: 1, + track: track.clone(), + }, + 0, + ); + assert!(state + .apply( + crate::playback::ListeningFact::Forward { + generation: 1, + track: track.clone(), + played_ms: 100_000, + }, + 0, + ) + .is_empty()); + assert!(state + .apply( + crate::playback::ListeningFact::Completed { + generation: 1, + track, + }, + 0, + ) + .is_empty()); + } + + #[test] + fn listening_provider_scrobbles_completion_fallback_once_per_repeat_generation() { + let track = track("Artist", "Album"); + let mut state = ListeningState::default(); + assert_eq!( + state + .apply( + crate::playback::ListeningFact::Started { + generation: 1, + track: track.clone(), + }, + 1234, + ) + .len(), + 1 + ); + assert_eq!( + state + .apply( + crate::playback::ListeningFact::Completed { + generation: 1, + track: track.clone(), + }, + 0, + ) + .len(), + 1 + ); + assert!(state + .apply( + crate::playback::ListeningFact::Completed { + generation: 1, + track: track.clone(), + }, + 0, + ) + .is_empty()); + assert_eq!( + state + .apply( + crate::playback::ListeningFact::Started { + generation: 2, + track: track.clone(), + }, + 5678, + ) + .len(), + 1 + ); + assert_eq!( + state + .apply( + crate::playback::ListeningFact::Completed { + generation: 2, + track, + }, + 0, + ) + .len(), + 1 + ); + } + + #[test] + fn batch_is_capped_and_response_codes_are_ordered() { + let mut items = (0..51) + .map(|index| Scrobble { + artist: "Artist".into(), + track: format!("Track {index}"), + album: None, + duration_secs: 60, + timestamp: index, + owner: "user".into(), + }) + .collect::>(); + let first_batch = next_batch(&items); + assert_eq!(first_batch.len(), 50); + assert_eq!(first_batch.first().map(|item| item.timestamp), Some(0)); + assert_eq!(first_batch.last().map(|item| item.timestamp), Some(49)); + let removed = apply_scrobble_results(&mut items, &first_batch, &vec![0; first_batch.len()]); + assert_eq!(removed.len(), 50); + let second_batch = next_batch(&items); + assert_eq!(second_batch.len(), 1); + assert_eq!(second_batch[0].timestamp, 50); + let response = serde_json::json!({ + "lfm": {"status": "ok", "scrobbles": {"scrobble": [ + {"ignoredMessage": {"code": "0"}}, + {"ignoredMessage": {"@code": "3"}} + ]}} + }); + assert_eq!(scrobble_codes(&response, 2), Some(vec![0, 3])); + } + + #[test] + fn ignored_old_records_are_removed_oldest_first() { + let first = queued_scrobble(1); + let second = queued_scrobble(2); + let mut queue = VecDeque::from([first.clone(), second.clone()]); + let batch = vec![first, second]; + let removed = apply_scrobble_results(&mut queue, &batch, &[3, 0]); + + assert_eq!(removed.len(), 2); + assert_eq!(removed[0].0.timestamp, 1); + assert_eq!(removed[0].1, 3); + assert!(queue.is_empty()); + } + + #[test] + fn disabling_retains_queue_and_reenable_makes_it_flushable() { + let mut runtime = Runtime { + enabled: true, + session: Some(LastFmSession { + username: "user".into(), + key: "session".into(), + }), + pending: None, + queue: VecDeque::from([queued_scrobble(1)]), + queue_owner: Some("user".into()), + reconnect_required: false, + build_problem: false, + storage_problem: false, + flushing: false, + queue_revision: 0, + }; + assert!(flush_ready(&runtime)); + let queued = runtime.queue.clone(); + runtime.enabled = false; + assert!(!flush_ready(&runtime)); + assert_eq!(runtime.queue, queued); + runtime.enabled = true; + assert!(flush_ready(&runtime)); + } + + #[test] + fn queue_persistence_does_not_hold_runtime_lock() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let mut service = Service::new(directory.path(), true, true); + let (entered, entered_rx) = std::sync::mpsc::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + Arc::get_mut(&mut service).unwrap().queue_store.blocker = Some(Arc::new(SaveBlocker { + entered, + release: std::sync::Mutex::new(release_rx), + })); + { + let mut runtime = service.runtime.lock().await; + runtime.queue_owner = Some("user".into()); + runtime.reconnect_required = true; + } + + let task_service = Arc::clone(&service); + let enqueue = tauri::async_runtime::spawn(async move { + task_service.enqueue(queued_scrobble(2)).await; + }); + tauri::async_runtime::spawn_blocking(move || { + entered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("queue persistence started"); + }) + .await + .unwrap(); + + let runtime = service.runtime.lock().await; + assert_eq!(runtime.queue.len(), 1); + drop(runtime); + + release.send(()).unwrap(); + enqueue.await.unwrap(); + }); + } + + #[test] + fn invalid_session_requires_reconnect_without_dropping_queue() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path(), true, true); + let session = LastFmSession { + username: "user".into(), + key: "session".into(), + }; + let queued = VecDeque::from([queued_scrobble(1)]); + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(session.clone()); + runtime.queue = queued.clone(); + runtime.queue_owner = Some("user".into()); + } + + assert!(service.invalidate_session(&session).await); + + let runtime = service.runtime.lock().await; + assert!(runtime.session.is_none()); + assert!(runtime.reconnect_required); + assert_eq!(runtime.queue, queued); + }); + } + + #[test] + fn same_user_reconnect_preserves_durable_queue() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path(), true, true); + let queued = VecDeque::from([queued_scrobble(1)]); + service.queue_store.save(&queued).unwrap(); + { + let mut runtime = service.runtime.lock().await; + runtime.queue = queued.clone(); + runtime.queue_owner = Some("user".into()); + } + + service.reconcile_queue_owner("user").await.unwrap(); + + let runtime = service.runtime.lock().await; + assert_eq!(runtime.queue, queued); + drop(runtime); + assert_eq!(service.queue_store.load().unwrap(), queued); + }); + } + + #[test] + fn different_user_reconnect_clears_durable_queue_before_installing() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path(), true, true); + let mut queued = VecDeque::from([queued_scrobble(1)]); + queued.front_mut().unwrap().owner = "old-user".into(); + service.queue_store.save(&queued).unwrap(); + let old_session = LastFmSession { + username: "old-user".into(), + key: "old-session".into(), + }; + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(old_session.clone()); + runtime.queue = queued.clone(); + runtime.queue_owner = Some("old-user".into()); + } + + service.reconcile_queue_owner("new-user").await.unwrap(); + + let runtime = service.runtime.lock().await; + assert!(runtime.queue.is_empty()); + assert!(runtime + .session + .as_ref() + .is_some_and(|session| session == &old_session)); + drop(runtime); + assert!(service.queue_store.load().unwrap().is_empty()); + }); + } + + #[test] + fn different_user_reconnect_keeps_old_runtime_state_when_queue_clear_fails() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + fs::create_dir(directory.path().join("lastfm-scrobbles.json")).unwrap(); + let service = Service::new(directory.path(), true, true); + let mut queued = VecDeque::from([queued_scrobble(1)]); + queued.front_mut().unwrap().owner = "old-user".into(); + let old_session = LastFmSession { + username: "old-user".into(), + key: "old-session".into(), + }; + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(old_session.clone()); + runtime.queue = queued.clone(); + runtime.queue_owner = Some("old-user".into()); + } + + assert!(service.reconcile_queue_owner("new-user").await.is_err()); + let runtime = service.runtime.lock().await; + assert_eq!(runtime.queue, queued); + assert!(runtime + .session + .as_ref() + .is_some_and(|session| session == &old_session)); + }); + } + + #[test] + fn api_nine_failure_preserves_queue_and_requires_reconnect() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path(), true, true); + let session = LastFmSession { + username: "user".into(), + key: "session".into(), + }; + let queued = VecDeque::from([queued_scrobble(1)]); + service.session_store.save(&session).unwrap(); + service.queue_store.save(&queued).unwrap(); + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(session.clone()); + runtime.queue = queued.clone(); + runtime.queue_owner = Some("user".into()); + } + + let message = service.handle_failure(Failure::Api(9)).await; + + assert!(message.contains("expired")); + let runtime = service.runtime.lock().await; + assert!(runtime.session.is_none()); + assert!(runtime.reconnect_required); + assert_eq!(runtime.queue, queued); + assert!(service.session_store.load().unwrap().is_none()); + assert_eq!(service.queue_store.load().unwrap(), queued); + }); + } + + #[test] + fn build_failure_stops_flush_without_dropping_session_or_queue() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let mut service = Service::new(directory.path(), true, true); + Arc::get_mut(&mut service).unwrap().credentials = Some(Credentials { + api_key: "test-key".into(), + shared_secret: "test-secret".into(), + }); + let session = LastFmSession { + username: "user".into(), + key: "session".into(), + }; + let queued = VecDeque::from([queued_scrobble(1)]); + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(session.clone()); + runtime.queue = queued.clone(); + runtime.queue_owner = Some("user".into()); + } + + let message = service.handle_failure(Failure::Api(10)).await; + + assert!(message.contains("app identity")); + let runtime = service.runtime.lock().await; + assert!(runtime.build_problem); + assert!(runtime + .session + .as_ref() + .is_some_and(|value| value == &session)); + assert_eq!(runtime.queue, queued); + assert!(!flush_ready(&runtime)); + drop(runtime); + let state = service.state().await; + assert!(!state.available); + assert_eq!( + state.problem.as_deref(), + Some("This Retune build cannot use Last.fm because its app identity was rejected.") + ); + }); + } + + #[test] + fn disconnect_keeps_session_and_queue_when_durable_clear_fails() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + fs::create_dir(directory.path().join("lastfm-scrobbles.json")).unwrap(); + let service = Service::new(directory.path(), true, true); + let session = LastFmSession { + username: "old-user".into(), + key: "old-session".into(), + }; + service.session_store.save(&session).unwrap(); + let queued = VecDeque::from([queued_scrobble(1)]); + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(session.clone()); + runtime.pending = Some("pending".into()); + runtime.queue = queued.clone(); + } + + assert!(service.disconnect().await.is_err()); + let runtime = service.runtime.lock().await; + assert!(runtime + .session + .as_ref() + .is_some_and(|value| value == &session)); + assert_eq!(runtime.pending.as_deref(), Some("pending")); + assert_eq!(runtime.queue, queued); + assert!(service + .session_store + .load() + .unwrap() + .as_ref() + .is_some_and(|value| value == &session)); + }); + } + + #[test] + fn disconnect_keeps_session_when_credential_clear_fails_after_queue_clear() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let mut service = Service::new(directory.path(), true, true); + Arc::get_mut(&mut service).unwrap().session_store = Arc::new(FailingClearSessionStore); + let session = LastFmSession { + username: "old-user".into(), + key: "old-session".into(), + }; + let queued = VecDeque::from([queued_scrobble(1)]); + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(session.clone()); + runtime.queue = queued; + } + + assert!(service.disconnect().await.is_err()); + assert!(service.queue_store.load().unwrap().is_empty()); + let runtime = service.runtime.lock().await; + assert!(runtime + .session + .as_ref() + .is_some_and(|value| value == &session)); + assert!(runtime.queue.is_empty()); + }); + } + + #[test] + fn cross_account_completion_commits_new_session_when_pending_clear_fails() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let service = Service::new(directory.path(), true, true); + let old_session = LastFmSession { + username: "old-user".into(), + key: "old-session".into(), + }; + let new_session = LastFmSession { + username: "new-user".into(), + key: "new-session".into(), + }; + let mut queued = VecDeque::from([queued_scrobble(1)]); + queued.front_mut().unwrap().owner = "old-user".into(); + service.session_store.save(&old_session).unwrap(); + service.queue_store.save(&queued).unwrap(); + fs::create_dir(&service.pending_store.path).unwrap(); + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(old_session.clone()); + runtime.pending = Some("pending-token".into()); + runtime.queue = queued.clone(); + runtime.queue_owner = Some("old-user".into()); + } + + service.commit_session(new_session.clone()).await.unwrap(); + assert!(service + .session_store + .load() + .unwrap() + .as_ref() + .is_some_and(|session| session == &new_session)); + let runtime = service.runtime.lock().await; + assert!(runtime + .session + .as_ref() + .is_some_and(|session| session == &new_session)); + assert!(runtime.pending.is_none()); + assert!(runtime.queue.is_empty()); + drop(runtime); + assert!(service.queue_store.load().unwrap().is_empty()); + }); + } + + #[test] + fn cross_account_completion_keeps_retry_state_when_session_save_fails() { + tauri::async_runtime::block_on(async { + let directory = tempfile::tempdir().unwrap(); + let mut service = Service::new(directory.path(), true, true); + let old_session = LastFmSession { + username: "old-user".into(), + key: "old-session".into(), + }; + let new_session = LastFmSession { + username: "new-user".into(), + key: "new-session".into(), + }; + let mut queued = VecDeque::from([queued_scrobble(1)]); + queued.front_mut().unwrap().owner = "old-user".into(); + Arc::get_mut(&mut service).unwrap().session_store = Arc::new(FailingSaveSessionStore { + session: old_session.clone(), + }); + service.pending_store.save("pending-token").unwrap(); + service.queue_store.save(&queued).unwrap(); + { + let mut runtime = service.runtime.lock().await; + runtime.session = Some(old_session.clone()); + runtime.pending = Some("pending-token".into()); + runtime.queue = queued.clone(); + runtime.queue_owner = Some("old-user".into()); + } + + assert!(service.commit_session(new_session).await.is_err()); + assert!(service + .session_store + .load() + .unwrap() + .as_ref() + .is_some_and(|session| session == &old_session)); + assert_eq!( + service.pending_store.load().unwrap().as_deref(), + Some("pending-token") + ); + assert_eq!(service.queue_store.load().unwrap(), queued); + let runtime = service.runtime.lock().await; + assert!(runtime + .session + .as_ref() + .is_some_and(|session| session == &old_session)); + assert_eq!(runtime.pending.as_deref(), Some("pending-token")); + assert_eq!(runtime.queue, queued); + }); + } + + #[test] + fn json_responses_support_last_fm_root_and_legacy_lfm_envelopes() { + let response = serde_json::json!({ + "session": {"name": "user", "key": "session"} + }); + assert_eq!( + response_text(&response, &["session", "name"]).as_deref(), + Some("user") + ); + assert_eq!(error_code(&serde_json::json!({"error": 9})), Some(9)); + assert_eq!( + scrobble_codes( + &serde_json::json!({ + "scrobbles": {"scrobble": {"ignoredMessage": {"code": 0}}} + }), + 1 + ), + Some(vec![0]) + ); + } + + #[test] + fn retry_schedule_is_ordered_and_bounded() { + assert_eq!(retry_delay(0), Duration::from_secs(1)); + assert_eq!(retry_delay(1), Duration::from_secs(5)); + assert_eq!(retry_delay(2), Duration::from_secs(15)); + assert_eq!(retry_delay(3), Duration::from_secs(60)); + assert_eq!(retry_delay(4), Duration::from_secs(300)); + assert!(retry_delay(0) < retry_delay(1)); + assert!(retry_delay(1) < retry_delay(2)); + assert_eq!(retry_delay(100), retry_delay(4)); + } + + #[test] + fn failure_classification_does_not_return_sensitive_details() { + assert!(is_build_failure(Failure::Api(10))); + assert!(is_retryable(Failure::Api(16))); + assert!(is_retryable(Failure::Response)); + let failure = Failure::Api(13); + assert_eq!(failure.code(), Some(13)); + } + + #[test] + fn log_messages_do_not_include_request_secrets() { + for line in include_str!("lastfm.rs") + .lines() + .filter(|line| line.contains("log::")) + { + for secret in [ + "api_key", + "shared_secret", + "api_sig", + "session.key", + "token", + ] { + assert!( + !line.contains(secret), + "sensitive value in log line: {line}" + ); + } + } + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e573f2d..5bd44d6 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,4 +1,6 @@ +mod diagnostics; mod fixture; +mod lastfm; mod library_commands; mod localfiles; mod media_keys; @@ -78,6 +80,7 @@ struct AppState { spotify: Mutex>>, artwork_cache: Mutex>>, playback: Arc, + lastfm: Arc, media_keys: media_keys::MediaKeys, sync_orchestrator: SyncOrchestrator, playlist_reauth_notified: AtomicBool, @@ -167,6 +170,10 @@ struct ExportSettings { #[serde(default)] playlist_hidden_columns: BTreeMap>, #[serde(default)] + playlist_column_orders: BTreeMap>, + #[serde(default)] + playlist_column_widths: BTreeMap>, + #[serde(default)] sort_column: Option, #[serde(default)] sort_desc: bool, @@ -187,6 +194,8 @@ impl ExportSettings { column_widths: settings.column_widths.clone(), hidden_columns: settings.hidden_columns.clone(), playlist_hidden_columns: settings.playlist_hidden_columns.clone(), + playlist_column_orders: settings.playlist_column_orders.clone(), + playlist_column_widths: settings.playlist_column_widths.clone(), sort_column: settings.sort_column.clone(), sort_desc: settings.sort_desc, shuffle: settings.shuffle, @@ -204,6 +213,8 @@ impl ExportSettings { settings.column_widths = self.column_widths; settings.hidden_columns = self.hidden_columns; settings.playlist_hidden_columns = self.playlist_hidden_columns; + settings.playlist_column_orders = self.playlist_column_orders; + settings.playlist_column_widths = self.playlist_column_widths; settings.sort_column = self.sort_column; settings.sort_desc = self.sort_desc; settings.shuffle = self.shuffle; @@ -688,6 +699,7 @@ async fn set_settings(app: tauri::AppHandle, mut settings: Settings) -> Result<( .save(&settings) .map_err(|error| error.to_string())?; *state.settings.lock().expect("settings mutex poisoned") = settings.clone(); + state.lastfm.set_enabled(settings.lastfm_scrobbling).await; state .playback .set_play_threshold_percent(settings.play_threshold_percent) @@ -703,6 +715,23 @@ async fn set_settings(app: tauri::AppHandle, mut settings: Settings) -> Result<( Ok(()) } +pub(crate) fn set_lastfm_scrobbling(app: &tauri::AppHandle, enabled: bool) -> Result<(), String> { + let state = app.state::(); + let mut settings = state + .settings + .lock() + .expect("settings mutex poisoned") + .clone(); + settings.lastfm_scrobbling = enabled; + state + .settings_store + .save(&settings) + .map_err(|error| error.to_string())?; + *state.settings.lock().expect("settings mutex poisoned") = settings.clone(); + app.emit("settings-changed", settings) + .map_err(|error| error.to_string()) +} + async fn switch_to_local(state: &AppState, volume: u8) -> Result<(), String> { state .token_store @@ -1294,6 +1323,15 @@ fn mark_album_membership(library: &Library, albums: &mut [provider::SearchAlbum] } } +fn mark_track_membership(library: &Library, tracks: &mut [provider::SearchTrack]) { + for track in tracks { + track.in_library = library + .tracks() + .iter() + .any(|candidate| candidate.source == SourceId::Music && candidate.uri == track.uri); + } +} + fn album_track_uris(album: &Album) -> Vec { album .tracks @@ -2029,10 +2067,12 @@ fn notify_error(app: &tauri::AppHandle, error: String) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let app = tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![ + .plugin(tauri_plugin_opener::init()); + #[cfg(all(desktop, not(test)))] + let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build()); + let app = builder.invoke_handler(tauri::generate_handler![ library_commands::browse, library_commands::metadata_values, library_commands::click_track_star, @@ -2072,7 +2112,6 @@ pub fn run() { playlist_commands::playlist_reorder, playlist_commands::playlist_remove, playback_commands::play_tracks, - playback_commands::replace_queue, playback_commands::player_toggle, playback_commands::player_next, playback_commands::player_prev, @@ -2081,7 +2120,13 @@ pub fn run() { playback_commands::set_repeat, playback_commands::set_shuffle, playback_commands::set_audio_settings, - spotify_commands::track_artwork + spotify_commands::track_artwork, + lastfm::lastfm_state, + lastfm::connect_lastfm, + lastfm::finish_lastfm, + lastfm::disconnect_lastfm, + diagnostics::load_diagnostics, + diagnostics::email_diagnostics ]) .setup(|app| { app.handle().plugin( @@ -2091,6 +2136,11 @@ pub fn run() { .timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal) .build(), )?; + log::info!( + target: diagnostics::LOG_TARGET, + "{}", + diagnostics::SESSION_START_MARKER + ); let app_data_dir = app.path().app_data_dir()?; let store = FsOverlayStore::new(&app_data_dir); let (mut library, recovery_notice, needs_save) = match store.load() { @@ -2134,6 +2184,11 @@ pub fn run() { Box::new(EncryptedFsTokenStore::new(&app_data_dir).map_err(std::io::Error::other)?) }; let token_store = Arc::new(CachedTokenStore::new(backing)); + let lastfm = lastfm::Service::new( + &app_data_dir, + use_dev_token_store, + settings.lastfm_scrobbling, + ); // Native credential-store access can fail transiently; start // disconnected rather than aborting startup. let connection = match token_store.load() { @@ -2179,6 +2234,7 @@ pub fn run() { )); playback.set_local_requested(settings.playback_backend == "local"); let media_keys = media_keys::MediaKeys::spawn(app.handle().clone()); + let lastfm_enabled = settings.lastfm_scrobbling; app.manage(AppState { library: Mutex::new(library), store, @@ -2193,26 +2249,37 @@ pub fn run() { spotify: Mutex::new(spotify), artwork_cache: Mutex::default(), playback: Arc::clone(&playback), + lastfm: Arc::clone(&lastfm), media_keys, sync_orchestrator: SyncOrchestrator::default(), playlist_reauth_notified: AtomicBool::new(false), }); + lastfm.attach_app(app.handle().clone()); + let lastfm_startup = Arc::clone(&lastfm); + tauri::async_runtime::spawn(async move { + lastfm_startup.set_enabled(lastfm_enabled).await; + }); let completion_app = app.handle().clone(); - playback.listen(app.handle().clone(), move |uri| { - let handle = completion_app.clone(); - drop(tauri::async_runtime::spawn_blocking(move || { - let state = handle.state::(); - match record_play(&state.store, &state.library, &uri, unix_now()) { - Ok(true) => { - if let Err(error) = handle.emit("library-changed", ()) { - notify_error(&handle, error.to_string()); + let lastfm = Arc::clone(&lastfm); + playback.listen( + app.handle().clone(), + move |uri| { + let handle = completion_app.clone(); + drop(tauri::async_runtime::spawn_blocking(move || { + let state = handle.state::(); + match record_play(&state.store, &state.library, &uri, unix_now()) { + Ok(true) => { + if let Err(error) = handle.emit("library-changed", ()) { + notify_error(&handle, error.to_string()); + } } + Ok(false) => {} + Err(error) => notify_error(&handle, error), } - Ok(false) => {} - Err(error) => notify_error(&handle, error), - } - })); - }); + })); + }, + move |fact| lastfm.handle_listening_fact(fact), + ); if activate_local || connection.connected || startup_action != StartupAction::Nothing @@ -2942,6 +3009,44 @@ mod tests { assert!(albums[0].in_library); } + #[test] + fn track_rows_reflect_library_track_identity() { + let mut library = Library::new(); + library.add(metadata_track( + "spotify:track:one", + "Rock", + "Artist", + "Album", + )); + let mut tracks = vec![ + provider::SearchTrack { + uri: "spotify:track:one".into(), + name: "One".into(), + artist: "Artist".into(), + alb: "Album".into(), + duration_secs: 1, + image_url: None, + album_uri: Some("spotify:album:one".into()), + in_library: false, + }, + provider::SearchTrack { + uri: "spotify:track:two".into(), + name: "Two".into(), + artist: "Artist".into(), + alb: "Album".into(), + duration_secs: 1, + image_url: None, + album_uri: Some("spotify:album:one".into()), + in_library: true, + }, + ]; + + mark_track_membership(&library, &mut tracks); + + assert!(tracks[0].in_library); + assert!(!tracks[1].in_library); + } + #[test] fn remove_album_tracks_removes_exactly_the_album_uris() { let mut library = Library::new(); @@ -3222,8 +3327,8 @@ mod tests { .to_vec(), column_widths: BTreeMap::from([("name".into(), 260), ("artist".into(), 140)]), hidden_columns: vec![ - "disc".into(), "genre".into(), + "disc".into(), "kind".into(), "bitrate".into(), "added".into(), @@ -3231,7 +3336,32 @@ mod tests { ], playlist_hidden_columns: BTreeMap::from([( "first".into(), - vec!["genre".into(), "plays".into()], + vec!["plays".into(), "genre".into()], + )]), + playlist_column_orders: BTreeMap::from([( + "first".into(), + [ + "genre", + "name", + "artist", + "album", + "time", + "rating", + "plays", + "disc", + "kind", + "bitrate", + "lastPlayed", + "added", + "releaseDate", + "track", + ] + .map(String::from) + .to_vec(), + )]), + playlist_column_widths: BTreeMap::from([( + "first".into(), + BTreeMap::from([("name".into(), 220), ("genre".into(), 120)]), )]), sort_column: Some("plays".into()), sort_desc: true, @@ -3248,6 +3378,7 @@ mod tests { normalize_volume: true, gapless: false, play_threshold_percent: 100, + lastfm_scrobbling: true, }; let plain = export_with_settings(&library, &exported, &playlists).unwrap(); // Gzip the export ourselves so import's GzDecoder path stays covered @@ -3279,6 +3410,14 @@ mod tests { restored.playlist_hidden_columns, exported.playlist_hidden_columns ); + assert_eq!( + restored.playlist_column_orders, + exported.playlist_column_orders + ); + assert_eq!( + restored.playlist_column_widths, + exported.playlist_column_widths + ); assert_eq!(restored.sort_column.as_deref(), Some("plays")); assert!(restored.sort_desc); assert!(restored.shuffle); @@ -3320,6 +3459,8 @@ mod tests { serde_json::json!(["track", "name", "time", "artist", "album", "genre", "rating"]), ); object.insert("hiddenColumns".into(), serde_json::json!(["name", "genre"])); + object.remove("playlistColumnOrders"); + object.remove("playlistColumnWidths"); object.remove("sortColumn"); object.remove("sortDesc"); let visual: ExportSettings = serde_json::from_value(json).unwrap(); @@ -3337,8 +3478,8 @@ mod tests { "album", "genre", "rating", - "disc", "plays", + "disc", "kind", "bitrate", "lastPlayed", @@ -3349,8 +3490,8 @@ mod tests { assert_eq!( settings.hidden_columns, [ - "disc", "genre", + "disc", "kind", "bitrate", "lastPlayed", @@ -3360,6 +3501,8 @@ mod tests { ); assert_eq!(settings.sort_column, None); assert!(!settings.sort_desc); + assert!(settings.playlist_column_orders.is_empty()); + assert!(settings.playlist_column_widths.is_empty()); } #[test] diff --git a/apps/desktop/src-tauri/src/playback/mod.rs b/apps/desktop/src-tauri/src/playback/mod.rs index 2677f04..a593f3b 100644 --- a/apps/desktop/src-tauri/src/playback/mod.rs +++ b/apps/desktop/src-tauri/src/playback/mod.rs @@ -156,6 +156,27 @@ pub struct SnapshotTrack { pub duration_secs: u64, } +#[derive(Clone, Debug)] +pub(crate) enum ListeningFact { + Started { + generation: u64, + track: SnapshotTrack, + }, + Forward { + generation: u64, + track: SnapshotTrack, + played_ms: u64, + }, + Discontinuity { + generation: u64, + track: SnapshotTrack, + }, + Completed { + generation: u64, + track: SnapshotTrack, + }, +} + #[derive(Clone, Debug)] pub(super) struct Snapshot { tracks: Vec, @@ -576,6 +597,7 @@ impl Playback { self: &Arc, app: tauri::AppHandle, on_track_completed: impl Fn(String) + Send + Sync + 'static, + on_listening: impl Fn(ListeningFact) + Send + Sync + 'static, ) { let mut receiver = self .receiver @@ -587,7 +609,7 @@ impl Playback { tauri::async_runtime::spawn(async move { while let Some(event) = receiver.recv().await { playback - .handle_event(&app, event, &on_track_completed) + .handle_event(&app, event, &on_track_completed, &on_listening) .await; } }); @@ -618,65 +640,6 @@ impl Playback { } } - pub async fn replace_queue( - &self, - client: Option>, - tracks: Vec, - index: usize, - ) -> Result { - if tracks.is_empty() || index >= tracks.len() { - return Err("Choose a track to play".into()); - } - let mut state = self.state.lock().await; - let current_uri = state - .reducer - .snapshot() - .map(|snapshot| snapshot.current().uri.as_str()) - .ok_or("Nothing is playing")?; - if tracks[index].uri != current_uri { - return Err("The replacement queue must keep the current track".into()); - } - let snapshot = Snapshot::new_with(tracks, index, state.reducer.shuffle(), |suffix| { - suffix.shuffle(&mut rand::rng()) - }); - let repeat = state.reducer.repeat().to_owned(); - let preload = snapshot - .current() - .uri - .starts_with("spotify:") - .then(|| preload_track(&snapshot, &repeat)) - .flatten() - .filter(|track| track.uri.starts_with("spotify:")) - .cloned(); - if self.local_requested() { - if let Some(next) = &preload { - let client = require_spotify(client.as_deref())?; - if let Err(error) = self.ensure_local_backend(&mut state, client).await { - log_authorization_required("preload", &next.uri, &error); - return match error.with_target(next.id) { - error @ PlaybackError::AuthorizationRequired { .. } => { - Ok(PlayOutcome::PlaybackAuthorizationRequired( - error - .into_prompt(next.id) - .expect("authorization errors produce prompts"), - )) - } - error => Err(error.into_string()), - }; - } - } - } - state.reducer.set_snapshot(Some(snapshot.clone())); - state - .backend - .set_shuffle_snapshot(Some(snapshot.clone()), &repeat) - .await; - if let Some(next) = preload { - state.backend.preload(&next.uri)?; - } - Ok(PlayOutcome::Started) - } - async fn play_with( &self, client: Option>, @@ -1159,9 +1122,13 @@ impl Playback { app: &tauri::AppHandle, event: NeutralEvent, on_track_completed: &impl Fn(String), + on_listening: &impl Fn(ListeningFact), ) { let mut state = self.state.lock().await; let actions = state.reducer.handle(event); + for fact in state.reducer.take_listening_facts() { + on_listening(fact); + } for action in actions { match action { ReducerAction::Emit(event) => { @@ -1565,112 +1532,6 @@ mod tests { assert!(state.reducer.state().shuffle); } - #[tokio::test] - async fn replacing_queue_preserves_the_current_load_and_repeat_policy() { - let playback = Playback::default(); - playback.play(None, file_tracks(1), 0).await.unwrap(); - playback.set_repeat(None, "all").await.unwrap(); - let before = playback.state.lock().await.file.request_id(); - - assert_eq!( - playback - .replace_queue(None, file_tracks(2), 1) - .await - .unwrap_err(), - "The replacement queue must keep the current track" - ); - playback - .replace_queue(None, file_tracks(3), 0) - .await - .unwrap(); - - { - let mut state = playback.state.lock().await; - let snapshot = state.reducer.snapshot().unwrap(); - assert_eq!(state.file.request_id(), before); - assert_eq!(snapshot.current().id, 1); - assert_eq!(snapshot.len(), 3); - assert_eq!(state.reducer.repeat(), "all"); - let generation = state.generation; - assert!(state - .reducer - .handle(NeutralEvent::RequestIdChanged { - generation, - request_id: before, - }) - .is_empty()); - assert!(matches!( - state - .reducer - .handle(NeutralEvent::EndOfTrack { - generation, - request_id: before, - uri: "file:///definitely/missing/1.mp3".into(), - }) - .as_slice(), - [ReducerAction::TrackCompleted(_), ReducerAction::Advance] - )); - playback.step_locked(&mut state, None, 1).await.unwrap(); - assert_eq!(state.reducer.snapshot().unwrap().current().id, 2); - } - - for expected in [3, 1] { - playback.next(None).await.unwrap(); - assert_eq!( - playback - .state - .lock() - .await - .reducer - .snapshot() - .unwrap() - .current() - .id, - expected - ); - } - } - - #[tokio::test] - async fn replacing_queue_waits_for_playback_authorization_before_mutating() { - let playback = Playback::default(); - playback.set_local_requested(true); - let mut current = mixed_tracks().pop().unwrap(); - current.id = 10; - current.uri = "spotify:track:current".into(); - let mut next = current.clone(); - next.id = 11; - next.uri = "spotify:track:next".into(); - playback - .state - .lock() - .await - .reducer - .set_snapshot(Some(Snapshot::new(vec![current.clone()], 0))); - - let outcome = playback - .replace_queue( - Some(client_without_playback_credentials()), - vec![current, next], - 0, - ) - .await - .unwrap(); - - assert!(matches!( - outcome, - PlayOutcome::PlaybackAuthorizationRequired(PlaybackAuthorizationPrompt { - reason: PlaybackAuthorizationReason::Missing, - target_track_id: 11, - .. - }) - )); - let state = playback.state.lock().await; - let snapshot = state.reducer.snapshot().unwrap(); - assert_eq!(snapshot.len(), 1); - assert_eq!(snapshot.current().id, 10); - } - #[tokio::test] async fn enabled_shuffle_constructs_exact_queue_then_event_advance_and_prev_follow_it() { let playback = Playback::default(); @@ -2030,6 +1891,31 @@ mod tests { assert!(state.file.is_active()); } + #[tokio::test] + async fn repeat_off_stops_after_the_remaining_tracks() { + let playback = Playback::default(); + playback.play(None, file_tracks(5), 2).await.unwrap(); + + playback.next(None).await.unwrap(); + playback.next(None).await.unwrap(); + + { + let state = playback.state.lock().await; + let snapshot = state.reducer.snapshot().unwrap(); + assert_eq!(snapshot.order, [0, 1, 2, 3, 4]); + assert_eq!(snapshot.index, 4); + assert_eq!(snapshot.current().id, 5); + } + + playback.next(None).await.unwrap(); + let state = playback.state.lock().await; + let snapshot = state.reducer.snapshot().unwrap(); + assert_eq!(snapshot.order, [0, 1, 2, 3, 4]); + assert_eq!(snapshot.index, 4); + assert_eq!(snapshot.current().id, 5); + assert!(!state.file.is_active()); + } + #[tokio::test] async fn repeat_one_completion_reloads_shuffled_active_current() { let playback = Playback::default(); diff --git a/apps/desktop/src-tauri/src/playback/reducer.rs b/apps/desktop/src-tauri/src/playback/reducer.rs index e1c8f1e..7623fdf 100644 --- a/apps/desktop/src-tauri/src/playback/reducer.rs +++ b/apps/desktop/src-tauri/src/playback/reducer.rs @@ -1,6 +1,14 @@ use std::collections::VecDeque; -use super::{empty_event, local_event, NeutralEvent, NeutralState, PlayerStateEvent, Snapshot}; +use super::{ + empty_event, local_event, ListeningFact, NeutralEvent, NeutralState, PlayerStateEvent, Snapshot, +}; + +fn duration_secs_ceil(duration_ms: Option) -> u64 { + duration_ms + .map(|value| u64::from(value).saturating_add(999) / 1000) + .unwrap_or_default() +} #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum ReducerAction { @@ -33,6 +41,12 @@ pub(super) struct EventReducer { play_threshold_percent: u8, previous_position_ms: u32, counted: bool, + listening_uri: Option, + listening_track: Option, + listening_generation: u64, + played_ms: u64, + last_progress_ms: u32, + listening_facts: VecDeque, shuffle: bool, } @@ -50,6 +64,12 @@ impl Default for EventReducer { play_threshold_percent: 100, previous_position_ms: 0, counted: false, + listening_uri: None, + listening_track: None, + listening_generation: 0, + played_ms: 0, + last_progress_ms: 0, + listening_facts: VecDeque::new(), shuffle: false, } } @@ -62,6 +82,7 @@ impl EventReducer { self.current = None; self.latest_intent = None; self.reset_playthrough(); + self.listening_facts.clear(); } pub(super) fn recover(&mut self, generation: u64) { @@ -69,6 +90,8 @@ impl EventReducer { self.pending.clear(); self.current = None; self.latest_intent = None; + self.reset_listening(); + self.listening_facts.clear(); } pub(super) fn set_snapshot(&mut self, snapshot: Option) { @@ -118,6 +141,10 @@ impl EventReducer { self.play_threshold_percent = percent; } + pub(super) fn take_listening_facts(&mut self) -> Vec { + self.listening_facts.drain(..).collect() + } + pub(super) fn queue_load(&mut self, uri: &str, playing: bool) { self.reset_playthrough(); self.next_intent = self.next_intent.wrapping_add(1); @@ -171,7 +198,7 @@ impl EventReducer { .. } => { if self.accepts(request_id, &uri) { - self.emit_track(&uri, position_ms, true) + self.track_event(&uri, position_ms, true) } else { vec![] } @@ -183,7 +210,7 @@ impl EventReducer { .. } => { if self.accepts(request_id, &uri) { - self.emit_track(&uri, position_ms, false) + self.track_event(&uri, position_ms, false) } else { vec![] } @@ -219,7 +246,15 @@ impl EventReducer { return vec![]; } self.previous_position_ms = position_ms; + self.last_progress_ms = position_ms; self.state.elapsed = u64::from(position_ms) / 1000; + if let Some(track) = self.listening_track_for(&uri) { + self.listening_facts + .push_back(ListeningFact::Discontinuity { + generation: self.listening_generation, + track, + }); + } vec![ReducerAction::Emit(self.state.clone())] } NeutralEvent::Unavailable { @@ -303,6 +338,13 @@ impl EventReducer { }) } + fn listening_track_for(&self, uri: &str) -> Option { + self.listening_track + .as_ref() + .filter(|track| track.uri == uri) + .cloned() + } + fn emit_track(&mut self, uri: &str, position_ms: u32, playing: bool) -> Vec { let Some(track) = self.track(uri).cloned() else { return vec![ReducerAction::Error(format!( @@ -320,6 +362,61 @@ impl EventReducer { vec![ReducerAction::Emit(self.state.clone())] } + fn track_event(&mut self, uri: &str, position_ms: u32, playing: bool) -> Vec { + let mut actions = self.emit_track(uri, position_ms, playing); + if !playing { + return actions; + } + actions.extend(self.start_and_progress(uri, position_ms)); + actions + } + + fn start_and_progress(&mut self, uri: &str, position_ms: u32) -> Vec { + let Some(track) = self.track(uri).cloned() else { + return vec![]; + }; + self.start_and_progress_track(track, position_ms) + } + + fn start_and_progress_track( + &mut self, + track: super::SnapshotTrack, + position_ms: u32, + ) -> Vec { + let uri = track.uri.clone(); + let actions = Vec::with_capacity(2); + if self.listening_uri.as_deref() != Some(uri.as_str()) { + self.reset_listening(); + self.listening_uri = Some(uri.clone()); + self.listening_generation = self.listening_generation.wrapping_add(1); + self.played_ms = 0; + self.last_progress_ms = position_ms; + self.listening_facts.push_back(ListeningFact::Started { + generation: self.listening_generation, + track: track.clone(), + }); + } + self.listening_track = Some(track.clone()); + let delta = position_ms.saturating_sub(self.last_progress_ms); + if position_ms < self.last_progress_ms || delta > 30_000 { + self.last_progress_ms = position_ms; + self.listening_facts + .push_back(ListeningFact::Discontinuity { + generation: self.listening_generation, + track, + }); + } else if delta > 0 { + self.played_ms = self.played_ms.saturating_add(u64::from(delta)); + self.last_progress_ms = position_ms; + self.listening_facts.push_back(ListeningFact::Forward { + generation: self.listening_generation, + track, + played_ms: self.played_ms, + }); + } + actions + } + fn position_changed( &mut self, request_id: u64, @@ -345,11 +442,15 @@ impl EventReducer { self.counted = true; actions.push(ReducerAction::TrackCompleted(uri.to_owned())); } + if self.state.is_playing && self.listening_uri.is_some() { + actions.extend(self.start_and_progress(uri, position_ms)); + } actions } fn complete(&mut self, uri: String) -> Vec { - let mut actions = Vec::with_capacity(2); + let mut actions = Vec::with_capacity(3); + self.finish_listening(); if !self.counted { self.counted = true; actions.push(ReducerAction::TrackCompleted(uri)); @@ -365,12 +466,50 @@ impl EventReducer { fn reset_playthrough(&mut self) { self.previous_position_ms = 0; self.counted = false; + self.reset_listening(); + } + + fn reset_listening(&mut self) { + self.listening_uri = None; + self.listening_track = None; + self.played_ms = 0; + self.last_progress_ms = 0; + } + + fn finish_listening(&mut self) { + let Some(uri) = self.listening_uri.as_deref() else { + return; + }; + let Some(track) = self + .listening_track + .as_ref() + .filter(|track| track.uri == uri) + .cloned() + else { + return; + }; + self.listening_facts.push_back(ListeningFact::Completed { + generation: self.listening_generation, + track, + }); } fn connect_state(&mut self, state: NeutralState) -> Vec { - self.previous_position_ms = state.position_ms; - self.state = if state.external { - PlayerStateEvent { + let changed_track = self.listening_uri.as_deref() != state.uri.as_deref(); + if changed_track && self.listening_uri.is_some() { + self.reset_listening(); + } + if state.external { + let external_track = state.uri.as_ref().map(|uri| super::SnapshotTrack { + id: 0, + uri: uri.clone(), + name: state.name.clone().unwrap_or_default(), + art: state.art.clone().unwrap_or_default(), + alb: state.alb.clone().unwrap_or_default(), + duration_secs: duration_secs_ceil(state.duration_ms), + }); + self.previous_position_ms = state.position_ms; + self.state = PlayerStateEvent { track_id: None, uri: state.uri, elapsed: u64::from(state.position_ms) / 1000, @@ -382,29 +521,52 @@ impl EventReducer { duration_secs: state.duration_ms.map(|value| u64::from(value) / 1000), volume_supported: state.volume_supported, shuffle: self.shuffle, + }; + let mut actions = Vec::with_capacity(2); + if self.state.is_playing { + if let Some(track) = external_track { + actions.extend(self.start_and_progress_track(track, state.position_ms)); + } } - } else if let Some(uri) = state.uri { + actions.insert(0, ReducerAction::Emit(self.state.clone())); + return actions; + } + let mut actions = Vec::with_capacity(3); + let position_ms = state.position_ms; + self.previous_position_ms = position_ms; + let missing_uri = state.uri.clone(); + let Some(current) = self.current_state(state) else { + return vec![ReducerAction::Error(format!( + "Playback returned a track outside the active queue: {}", + missing_uri.unwrap_or_default() + ))]; + }; + self.state = current; + if let Some(uri) = self.state.uri.clone().filter(|_| self.state.is_playing) { + actions.extend(self.start_and_progress(&uri, position_ms)); + } + actions.insert(0, ReducerAction::Emit(self.state.clone())); + actions + } + + fn current_state(&mut self, state: NeutralState) -> Option { + if let Some(uri) = state.uri { if let Some(snapshot) = &mut self.snapshot { if let Some(index) = snapshot.active_position(&uri) { snapshot.index = index; } } - let Some(track) = self.track(&uri).cloned() else { - return vec![ReducerAction::Error(format!( - "Playback returned a track outside the active queue: {uri}" - ))]; - }; - local_event( + let track = self.track(&uri).cloned()?; + Some(local_event( &track, u64::from(state.position_ms) / 1000, state.is_playing, state.volume_supported, self.shuffle, - ) + )) } else { - empty_event(false, self.shuffle) - }; - vec![ReducerAction::Emit(self.state.clone())] + Some(empty_event(false, self.shuffle)) + } } } @@ -850,6 +1012,303 @@ mod tests { assert_eq!(state.elapsed, 2); } + #[test] + fn loading_does_not_emit_listening_facts_before_playback_begins() { + let mut reducer = reducer(); + bind(&mut reducer, "spotify:track:1", true, 1); + + reducer.handle(NeutralEvent::Loading { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 0, + }); + + assert!(reducer.take_listening_facts().is_empty()); + reducer.handle(NeutralEvent::Playing { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 0, + }); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Started { generation: 1, track }] if track.uri == "spotify:track:1" + )); + reducer.handle(NeutralEvent::Playing { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 1000, + }); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Forward { + generation: 1, + track, + played_ms: 1000, + }] if track.uri == "spotify:track:1" + )); + } + + #[test] + fn external_playback_emits_generation_scoped_listening_facts() { + let mut reducer = reducer(); + let external = |position_ms| NeutralEvent::ConnectState { + generation: 7, + state: NeutralState { + uri: Some("spotify:track:external".into()), + position_ms, + is_playing: true, + external: true, + name: Some("External Song".into()), + art: Some("External Artist".into()), + alb: Some("External Album".into()), + duration_ms: Some(120_000), + volume_supported: false, + }, + }; + + let state = emitted(reducer.handle(external(0))); + assert!(state.external); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Started { generation: 1, track }] + if track.id == 0 && track.uri == "spotify:track:external" + )); + + reducer.handle(external(30_000)); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Forward { + generation: 1, + played_ms: 30_000, + .. + }] + )); + + reducer.handle(external(60_000)); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Forward { + generation: 1, + track, + played_ms: 60_000, + }] if track.uri == "spotify:track:external" + )); + + reducer.handle(external(90_000)); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Forward { + played_ms: 90_000, + .. + }] + )); + } + + #[test] + fn external_duration_30_001ms_keeps_cumulative_forward_time() { + let mut reducer = reducer(); + let external = |position_ms| NeutralEvent::ConnectState { + generation: 7, + state: NeutralState { + uri: Some("spotify:track:external-short-edge".into()), + position_ms, + is_playing: true, + external: true, + name: Some("External Song".into()), + art: Some("External Artist".into()), + alb: Some("External Album".into()), + duration_ms: Some(30_001), + volume_supported: false, + }, + }; + + reducer.handle(external(0)); + reducer.take_listening_facts(); + reducer.handle(external(15_000)); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Forward { + played_ms: 15_000, + .. + }] + )); + reducer.handle(external(16_000)); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Forward { + track, + played_ms: 16_000, + .. + }] if track.duration_secs == 31 + )); + } + + #[test] + fn switching_tracks_starts_a_new_listening_fact() { + let mut reducer = reducer(); + reducer.handle(NeutralEvent::ConnectState { + generation: 7, + state: NeutralState { + uri: Some("spotify:track:1".into()), + position_ms: 0, + is_playing: true, + external: false, + name: None, + art: None, + alb: None, + duration_ms: None, + volume_supported: true, + }, + }); + reducer.take_listening_facts(); + + reducer.handle(NeutralEvent::ConnectState { + generation: 7, + state: NeutralState { + uri: Some("spotify:track:2".into()), + position_ms: 0, + is_playing: true, + external: false, + name: None, + art: None, + alb: None, + duration_ms: None, + volume_supported: true, + }, + }); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Started { generation: 2, track }] if track.uri == "spotify:track:2" + )); + } + + #[test] + fn listening_facts_accumulate_forward_time_once() { + let mut reducer = reducer(); + bind(&mut reducer, "spotify:track:1", true, 1); + reducer.handle(NeutralEvent::Playing { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 0, + }); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Started { track, .. }] if track.uri == "spotify:track:1" + )); + + for position_ms in [10_000, 20_000, 30_000, 40_000, 50_000] { + reducer.handle(NeutralEvent::PositionChanged { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms, + }); + } + let facts = reducer.take_listening_facts(); + assert_eq!(facts.len(), 5); + assert!(matches!( + facts.last(), + Some(ListeningFact::Forward { + track, + played_ms: 50_000, + .. + }) if track.uri == "spotify:track:1" + )); + + reducer.handle(NeutralEvent::PositionChanged { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 60_000, + }); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Forward { + played_ms: 60_000, + .. + }] + )); + } + + #[test] + fn listening_seek_and_stale_events_are_generation_scoped() { + let mut reducer = reducer(); + bind(&mut reducer, "spotify:track:1", true, 1); + reducer.handle(NeutralEvent::Playing { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 0, + }); + reducer.take_listening_facts(); + assert!(reducer + .handle(NeutralEvent::PositionChanged { + generation: 6, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 60_000, + }) + .is_empty()); + assert!(reducer.take_listening_facts().is_empty()); + + reducer.handle(NeutralEvent::Seeked { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 80_000, + }); + reducer.handle(NeutralEvent::EndOfTrack { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + }); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ + ListeningFact::Discontinuity { generation: 1, .. }, + ListeningFact::Completed { generation: 1, .. }, + ] + )); + } + + #[test] + fn listening_completion_and_repeat_use_separate_facts() { + let mut reducer = reducer(); + bind(&mut reducer, "spotify:track:1", true, 1); + reducer.handle(NeutralEvent::Playing { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 0, + }); + reducer.take_listening_facts(); + reducer.handle(NeutralEvent::EndOfTrack { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + }); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Completed { generation: 1, track }] if track.uri == "spotify:track:1" + )); + + bind(&mut reducer, "spotify:track:1", true, 2); + reducer.handle(NeutralEvent::Playing { + generation: 7, + request_id: 2, + uri: "spotify:track:1".into(), + position_ms: 0, + }); + assert!(matches!( + reducer.take_listening_facts().as_slice(), + [ListeningFact::Started { generation: 2, track }] if track.uri == "spotify:track:1" + )); + } + #[test] fn position_changed_ticks_without_changing_play_state() { let mut reducer = reducer(); diff --git a/apps/desktop/src-tauri/src/playback_commands.rs b/apps/desktop/src-tauri/src/playback_commands.rs index 546f314..8943dc6 100644 --- a/apps/desktop/src-tauri/src/playback_commands.rs +++ b/apps/desktop/src-tauri/src/playback_commands.rs @@ -22,20 +22,6 @@ pub(super) async fn play_tracks( Ok(outcome) } -#[tauri::command(rename_all = "camelCase")] -pub(super) async fn replace_queue( - app: tauri::AppHandle, - snapshot: Vec, - current_index: usize, -) -> Result { - let state = app.state::(); - let client = provider_from(&state).ok(); - state - .playback - .replace_queue(client, snapshot, current_index) - .await -} - #[tauri::command] pub(super) async fn player_toggle(app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); diff --git a/apps/desktop/src-tauri/src/provider.rs b/apps/desktop/src-tauri/src/provider.rs index c8eff32..2d9898f 100644 --- a/apps/desktop/src-tauri/src/provider.rs +++ b/apps/desktop/src-tauri/src/provider.rs @@ -1,6 +1,6 @@ use retune_core::model::NewTrack; use retune_spotify::{ - client::{endpoint_family, Album, Artist, Image, SpotifyClient, Transport}, + client::{endpoint_family, Album, Artist, Image, Page, SpotifyClient, Transport}, normalize, tokens::TokenStore, }; @@ -105,13 +105,23 @@ pub struct SearchTrack { pub duration_secs: u64, pub image_url: Option, pub album_uri: Option, + pub in_library: bool, } #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchGroup { + pub items: Vec, + pub total: u32, + pub next_offset: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] pub struct SearchResults { - pub artists: Vec, - pub albums: Vec, - pub tracks: Vec, + pub artists: SearchGroup, + pub albums: SearchGroup, + pub tracks: SearchGroup, } pub struct Snapshot { @@ -900,51 +910,52 @@ impl MediaProvider for SpotifyClient { } } +fn search_group(page: Page, offset: u32, map: impl FnMut(T) -> U) -> SearchGroup { + let count = (page.items.len() + page.skipped) as u32; + SearchGroup { + items: page.items.into_iter().map(map).collect(), + total: page.total, + next_offset: (page.next.is_some() && count > 0).then_some(offset + count), + } +} + pub async fn search( client: &SpotifyClient, query: &str, + offset: u32, ) -> Result { - let results = SpotifyClient::search(client, query, 0, SEARCH_PAGE_SIZE) + let results = SpotifyClient::search(client, query, offset, SEARCH_PAGE_SIZE) .await .map_err(|error| error.to_string())?; Ok(SearchResults { - artists: results - .artists - .items - .into_iter() - .map(|artist| SearchArtist { - id: artist.id.clone(), - descriptor: artist_descriptor(&artist), - image_url: image_url(&artist.images), - name: artist.name, - }) - .collect(), - albums: results.albums.items.into_iter().map(search_album).collect(), - tracks: results - .tracks - .items - .into_iter() - .map(|track| SearchTrack { - uri: track.uri, - name: track.name, - artist: track - .artists - .first() - .map(|artist| artist.name.clone()) - .unwrap_or_default(), - alb: track - .album - .as_ref() - .map(|album| album.name.clone()) - .unwrap_or_default(), - duration_secs: track.duration_ms.unwrap_or_default() / 1_000, - image_url: track - .album - .as_ref() - .and_then(|album| image_url(&album.images)), - album_uri: track.album.map(|album| album.uri), - }) - .collect(), + artists: search_group(results.artists, offset, |artist| SearchArtist { + id: artist.id.clone(), + descriptor: artist_descriptor(&artist), + image_url: image_url(&artist.images), + name: artist.name, + }), + albums: search_group(results.albums, offset, search_album), + tracks: search_group(results.tracks, offset, |track| SearchTrack { + uri: track.uri, + name: track.name, + artist: track + .artists + .first() + .map(|artist| artist.name.clone()) + .unwrap_or_default(), + alb: track + .album + .as_ref() + .map(|album| album.name.clone()) + .unwrap_or_default(), + duration_secs: track.duration_ms.unwrap_or_default() / 1_000, + image_url: track + .album + .as_ref() + .and_then(|album| image_url(&album.images)), + album_uri: track.album.map(|album| album.uri), + in_library: false, + }), }) } @@ -1771,10 +1782,10 @@ mod tests { }), )]); - let results = search(&client, "artist").await.unwrap(); + let results = search(&client, "artist", 0).await.unwrap(); assert_eq!( - results.artists[0], + results.artists.items[0], SearchArtist { id: "artist-1".into(), name: "Artist".into(), @@ -1782,9 +1793,9 @@ mod tests { image_url: Some("small".into()), } ); - assert_eq!(results.albums[0].year.as_deref(), Some("2024")); + assert_eq!(results.albums.items[0].year.as_deref(), Some("2024")); assert_eq!( - results.tracks[0], + results.tracks.items[0], SearchTrack { uri: "spotify:track:1".into(), name: "Track".into(), @@ -1793,6 +1804,7 @@ mod tests { duration_secs: 123, image_url: Some("track".into()), album_uri: Some("spotify:album:1".into()), + in_library: false, } ); let url = url::Url::parse(&client.transport().requests()[0].url).unwrap(); @@ -1806,6 +1818,57 @@ mod tests { ); } + #[tokio::test] + async fn search_pages_expose_totals_and_next_offsets_for_empty_final_pages() { + let first_page = || { + Response::json( + 200, + serde_json::json!({ + "artists": {"items": (0..10).map(|index| serde_json::json!({"id": format!("artist-{index}"), "name": "Artist", "images": []})).collect::>(), "next": "next", "total": 10}, + "albums": {"items": [], "next": "next", "total": 10}, + "tracks": {"items": [], "next": "next", "total": 10} + }), + ) + }; + let client = client([ + first_page(), + Response::json( + 200, + serde_json::json!({ + "artists": {"items": [], "next": null, "total": 10}, + "albums": {"items": [], "next": null, "total": 10}, + "tracks": {"items": [], "next": null, "total": 10} + }), + ), + ]); + + let first = search(&client, "artist", 0).await.unwrap(); + let final_page = search(&client, "artist", 10).await.unwrap(); + + assert_eq!(first.artists.items.len(), 10); + assert_eq!(first.artists.total, 10); + assert_eq!(first.artists.next_offset, Some(10)); + assert!(final_page.artists.items.is_empty()); + assert_eq!(final_page.artists.next_offset, None); + let requests = client.transport().requests(); + assert_eq!(requests.len(), 2); + for (request, offset) in requests.iter().zip([0, 10]) { + let url = url::Url::parse(&request.url).unwrap(); + assert_eq!(url.path(), "/v1/search"); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "offset") + .unwrap() + .1, + offset.to_string() + ); + assert_eq!( + url.query_pairs().find(|(key, _)| key == "limit").unwrap().1, + "10" + ); + } + } + #[test] fn formats_compact_counts() { assert_eq!(compact_count(1_234_567), "1.2M"); diff --git a/apps/desktop/src-tauri/src/spotify_commands.rs b/apps/desktop/src-tauri/src/spotify_commands.rs index 80265ae..54de940 100644 --- a/apps/desktop/src-tauri/src/spotify_commands.rs +++ b/apps/desktop/src-tauri/src/spotify_commands.rs @@ -1,4 +1,5 @@ use super::*; +use crate::provider::SearchGroup; use librespot_core::{authentication::Credentials, config::SessionConfig, session::Session}; use retune_spotify::tokens::PlaybackCredentials; @@ -202,22 +203,39 @@ pub(super) async fn sync_from_spotify(app: tauri::AppHandle) -> Result<(), Strin pub(super) async fn spotify_search( state: tauri::State<'_, AppState>, query: String, + offset: u32, ) -> Result { if query.trim().is_empty() { return Ok(SearchResults { - artists: vec![], - albums: vec![], - tracks: vec![], + artists: SearchGroup { + items: vec![], + total: 0, + next_offset: None, + }, + albums: SearchGroup { + items: vec![], + total: 0, + next_offset: None, + }, + tracks: SearchGroup { + items: vec![], + total: 0, + next_offset: None, + }, }); } if !stored_connection_state(&state.token_store)?.connected { return Err("Connect to Spotify to search.".into()); } let provider = provider_from(&state)?; - let mut results = provider::search(provider.as_ref(), query.trim()).await?; + let mut results = provider::search(provider.as_ref(), query.trim(), offset).await?; mark_album_membership( &state.library.lock().expect("library mutex poisoned"), - &mut results.albums, + &mut results.albums.items, + ); + mark_track_membership( + &state.library.lock().expect("library mutex poisoned"), + &mut results.tracks.items, ); Ok(results) } diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 4f91733..d4f3b88 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -94,6 +94,10 @@ pub struct Settings { #[serde(default)] pub playlist_hidden_columns: BTreeMap>, #[serde(default)] + pub playlist_column_orders: BTreeMap>, + #[serde(default)] + pub playlist_column_widths: BTreeMap>, + #[serde(default)] pub sort_column: Option, #[serde(default)] pub sort_desc: bool, @@ -122,6 +126,8 @@ pub struct Settings { pub gapless: bool, #[serde(default = "default_play_threshold_percent")] pub play_threshold_percent: u8, + #[serde(default = "default_true")] + pub lastfm_scrobbling: bool, } #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] @@ -186,6 +192,8 @@ impl Default for Settings { column_widths: BTreeMap::new(), hidden_columns: Self::OPTIONAL_COLUMNS.map(String::from).to_vec(), playlist_hidden_columns: BTreeMap::new(), + playlist_column_orders: BTreeMap::new(), + playlist_column_widths: BTreeMap::new(), sort_column: None, sort_desc: false, auto_add_spotify_library: true, @@ -201,12 +209,29 @@ impl Default for Settings { normalize_volume: false, gapless: true, play_threshold_percent: default_play_threshold_percent(), + lastfm_scrobbling: true, } } } impl Settings { const COLUMNS: [&'static str; 14] = [ + "track", + "name", + "artist", + "album", + "time", + "plays", + "rating", + "genre", + "disc", + "kind", + "bitrate", + "lastPlayed", + "added", + "releaseDate", + ]; + const LEGACY_DEFAULT_COLUMN_ORDER: [&'static str; 14] = [ "name", "artist", "album", @@ -222,6 +247,22 @@ impl Settings { "added", "releaseDate", ]; + const PLAYLIST_COLUMNS: [&'static str; 14] = [ + "name", + "artist", + "album", + "time", + "rating", + "plays", + "genre", + "disc", + "kind", + "bitrate", + "lastPlayed", + "added", + "releaseDate", + "track", + ]; const OPTIONAL_COLUMNS: [&'static str; 6] = [ "disc", "kind", @@ -230,7 +271,24 @@ impl Settings { "added", "releaseDate", ]; + const PLAYLIST_OPTIONAL_COLUMNS: [&'static str; 7] = [ + "disc", + "kind", + "bitrate", + "lastPlayed", + "added", + "releaseDate", + "track", + ]; pub(crate) fn normalize(&mut self) { + if self + .column_order + .iter() + .map(String::as_str) + .eq(Self::LEGACY_DEFAULT_COLUMN_ORDER) + { + self.column_order = Self::COLUMNS.map(String::from).to_vec(); + } self.column_order .retain(|column| Self::COLUMNS.contains(&column.as_str())); self.column_widths @@ -250,16 +308,25 @@ impl Settings { self.column_order.push(column.into()); if Self::OPTIONAL_COLUMNS.contains(&column) { self.hidden_columns.push(column.into()); - for hidden_columns in self.playlist_hidden_columns.values_mut() { - hidden_columns.push(column.into()); - } } } } Self::normalize_hidden_columns(&mut self.hidden_columns); for hidden_columns in self.playlist_hidden_columns.values_mut() { - Self::normalize_hidden_columns(hidden_columns); + Self::normalize_playlist_hidden_columns(hidden_columns); + } + self.playlist_hidden_columns + .retain(|_, hidden_columns| !Self::is_default_playlist_hidden(hidden_columns)); + for order in self.playlist_column_orders.values_mut() { + Self::normalize_playlist_column_order(order); } + self.playlist_column_orders + .retain(|_, order| !Self::is_default_playlist_order(order)); + for widths in self.playlist_column_widths.values_mut() { + widths.retain(|column, _| Self::PLAYLIST_COLUMNS.contains(&column.as_str())); + } + self.playlist_column_widths + .retain(|_, widths| !widths.is_empty()); if !matches!(self.play_threshold_percent, 50 | 75 | 90 | 100) { self.play_threshold_percent = default_play_threshold_percent(); } @@ -288,10 +355,29 @@ impl Settings { || self .playlist_hidden_columns .values() - .any(|columns| !Self::hidden_columns_valid(columns)) + .any(|columns| !Self::playlist_hidden_columns_valid(columns)) { return Err(StoreError::InvalidSettings( - "settings hiddenColumns must be unique track columns other than name", + "settings hiddenColumns must contain unique known columns other than name", + )); + } + if self.playlist_column_orders.values().any(|order| { + order.len() != Self::PLAYLIST_COLUMNS.len() + || Self::PLAYLIST_COLUMNS + .iter() + .any(|column| order.iter().filter(|item| item.as_str() == *column).count() != 1) + }) { + return Err(StoreError::InvalidSettings( + "settings playlistColumnOrders must contain each playlist column exactly once", + )); + } + if self.playlist_column_widths.values().any(|widths| { + widths.iter().any(|(column, width)| { + !Self::PLAYLIST_COLUMNS.contains(&column.as_str()) || *width < 28 + }) + }) { + return Err(StoreError::InvalidSettings( + "settings playlistColumnWidths must contain playlist columns at least 28px wide", )); } if self @@ -353,6 +439,52 @@ impl Settings { && columns.iter().filter(|item| *item == column).count() == 1 }) } + + fn normalize_playlist_column_order(order: &mut Vec) { + let mut normalized = Vec::with_capacity(Self::PLAYLIST_COLUMNS.len()); + for column in order.drain(..) { + if Self::PLAYLIST_COLUMNS.contains(&column.as_str()) && !normalized.contains(&column) { + normalized.push(column); + } + } + for column in Self::PLAYLIST_COLUMNS { + if !normalized.iter().any(|item| item == column) { + normalized.push(column.into()); + } + } + *order = normalized; + } + + fn is_default_playlist_order(order: &[String]) -> bool { + order.iter().map(String::as_str).eq(Self::PLAYLIST_COLUMNS) + } + + fn is_default_playlist_hidden(columns: &[String]) -> bool { + columns + .iter() + .map(String::as_str) + .eq(Self::PLAYLIST_OPTIONAL_COLUMNS) + } + + fn normalize_playlist_hidden_columns(columns: &mut Vec) { + columns + .retain(|column| column != "name" && Self::PLAYLIST_COLUMNS.contains(&column.as_str())); + columns.sort_by_key(|column| { + Self::PLAYLIST_COLUMNS + .iter() + .position(|candidate| candidate == column) + .unwrap_or(usize::MAX) + }); + columns.dedup(); + } + + fn playlist_hidden_columns_valid(columns: &[String]) -> bool { + columns.iter().all(|column| { + column != "name" + && Self::PLAYLIST_COLUMNS.contains(&column.as_str()) + && columns.iter().filter(|item| *item == column).count() == 1 + }) + } } pub struct FsSettingsStore { @@ -649,15 +781,69 @@ mod tests { .to_vec(), column_widths: BTreeMap::from([("name".into(), 240), ("lastPlayed".into(), 120)]), hidden_columns: vec![ - "disc".into(), "genre".into(), + "disc".into(), "added".into(), "releaseDate".into(), ], - playlist_hidden_columns: BTreeMap::from([( - "road-trip".into(), - vec!["genre".into(), "plays".into()], - )]), + playlist_hidden_columns: BTreeMap::from([ + ("road-trip".into(), vec!["plays".into(), "genre".into()]), + ("focus".into(), vec!["disc".into(), "track".into()]), + ]), + playlist_column_orders: BTreeMap::from([ + ( + "road-trip".into(), + [ + "genre", + "name", + "artist", + "album", + "time", + "rating", + "plays", + "disc", + "kind", + "bitrate", + "lastPlayed", + "added", + "releaseDate", + "track", + ] + .map(String::from) + .to_vec(), + ), + ( + "focus".into(), + [ + "plays", + "name", + "artist", + "album", + "time", + "rating", + "genre", + "disc", + "kind", + "bitrate", + "lastPlayed", + "added", + "releaseDate", + "track", + ] + .map(String::from) + .to_vec(), + ), + ]), + playlist_column_widths: BTreeMap::from([ + ( + "road-trip".into(), + BTreeMap::from([("name".into(), 220), ("genre".into(), 120)]), + ), + ( + "focus".into(), + BTreeMap::from([("plays".into(), 180), ("genre".into(), 140)]), + ), + ]), sort_column: Some("artist".into()), sort_desc: true, auto_add_spotify_library: true, @@ -673,6 +859,7 @@ mod tests { normalize_volume: true, gapless: false, play_threshold_percent: 75, + lastfm_scrobbling: false, }; assert!(store.load().unwrap().is_none()); @@ -750,6 +937,16 @@ mod tests { assert_eq!(settings.play_threshold_percent, 100); } + #[test] + fn legacy_settings_enable_lastfm_scrobbling_by_default() { + let mut json = serde_json::to_value(Settings::default()).unwrap(); + json.as_object_mut().unwrap().remove("lastfmScrobbling"); + + let settings: Settings = serde_json::from_value(json).unwrap(); + + assert!(settings.lastfm_scrobbling); + } + #[test] fn settings_load_normalizes_invalid_play_threshold() { let dir = tempfile::tempdir().unwrap(); @@ -888,8 +1085,8 @@ mod tests { "album", "genre", "rating", - "disc", "plays", + "disc", "kind", "bitrate", "lastPlayed", @@ -943,8 +1140,8 @@ mod tests { "time", "genre", "rating", - "disc", "plays", + "disc", "kind", "bitrate", "lastPlayed", @@ -955,8 +1152,8 @@ mod tests { assert_eq!( settings.hidden_columns, [ - "disc", "genre", + "disc", "kind", "bitrate", "lastPlayed", @@ -1019,6 +1216,108 @@ mod tests { .ends_with(&["added".into(), "releaseDate".into()])); } + #[test] + fn settings_load_migrates_the_legacy_default_library_order() { + let dir = tempfile::tempdir().unwrap(); + let store = FsSettingsStore::new(dir.path()); + let legacy = serde_json::json!({ + "theme": "system", "zoom": 1.0, "zebra": true, + "columnOrder": ["name", "artist", "album", "disc", "track", "time", "rating", "genre", "plays", "kind", "bitrate", "lastPlayed", "added", "releaseDate"], + "autoAddSpotifyLibrary": true + }); + fs::write( + dir.path().join("settings.json"), + serde_json::to_vec(&legacy).unwrap(), + ) + .unwrap(); + + assert_eq!( + store.load().unwrap().unwrap().column_order, + Settings::default().column_order + ); + } + + #[test] + fn playlist_layout_defaults_remove_empty_and_default_overrides() { + let mut settings = Settings::default(); + settings.playlist_hidden_columns.insert( + "playlist".into(), + Settings::PLAYLIST_OPTIONAL_COLUMNS + .map(String::from) + .to_vec(), + ); + settings + .playlist_hidden_columns + .insert("all-visible".into(), vec![]); + settings.playlist_column_orders.insert( + "playlist".into(), + Settings::PLAYLIST_COLUMNS.map(String::from).to_vec(), + ); + settings + .playlist_column_widths + .insert("playlist".into(), BTreeMap::new()); + + settings.normalize(); + + assert!(!settings.playlist_hidden_columns.contains_key("playlist")); + assert_eq!( + settings.playlist_hidden_columns["all-visible"], + Vec::::new() + ); + assert!(settings.playlist_column_orders.is_empty()); + assert!(settings.playlist_column_widths.is_empty()); + } + + #[test] + fn playlist_layout_overrides_normalize_and_validate_at_the_boundary() { + let dir = tempfile::tempdir().unwrap(); + let store = FsSettingsStore::new(dir.path()); + let json = serde_json::json!({ + "theme": "system", "zoom": 1.0, "zebra": true, + "columnOrder": Settings::default().column_order, + "autoAddSpotifyLibrary": true, + "playlistHiddenColumns": {"a": ["genre", "track", "genre"]}, + "playlistColumnOrders": {"a": ["genre", "name", "artist"]}, + "playlistColumnWidths": {"a": {"name": 220, "future": 80}} + }); + fs::write( + dir.path().join("settings.json"), + serde_json::to_vec(&json).unwrap(), + ) + .unwrap(); + + let settings = store.load().unwrap().unwrap(); + assert_eq!( + settings.playlist_hidden_columns["a"], + vec!["genre".to_string(), "track".to_string()] + ); + assert_eq!( + settings.playlist_column_orders["a"], + [ + "genre", + "name", + "artist", + "album", + "time", + "rating", + "plays", + "disc", + "kind", + "bitrate", + "lastPlayed", + "added", + "releaseDate", + "track" + ] + .map(String::from) + .to_vec() + ); + assert_eq!( + settings.playlist_column_widths["a"], + BTreeMap::from([("name".into(), 220)]) + ); + } + #[test] fn legacy_settings_json_defaults_to_no_sort() { let settings: Settings = serde_json::from_value(serde_json::json!({ @@ -1031,6 +1330,8 @@ mod tests { assert_eq!(settings.sort_column, None); assert!(!settings.sort_desc); assert!(settings.playlist_hidden_columns.is_empty()); + assert!(settings.playlist_column_orders.is_empty()); + assert!(settings.playlist_column_widths.is_empty()); } #[test] @@ -1132,6 +1433,31 @@ mod tests { assert!(settings.validate().is_err()); } + #[test] + fn playlist_layout_validation_rejects_invalid_columns_and_widths() { + let mut settings = Settings::default(); + settings + .playlist_column_orders + .insert("playlist".into(), vec!["name".into()]); + assert!(settings.validate().is_err()); + + settings.playlist_column_orders.clear(); + settings + .playlist_hidden_columns + .insert("playlist".into(), vec!["track".into()]); + assert!(settings.validate().is_ok()); + + settings.playlist_hidden_columns.clear(); + settings + .playlist_column_widths + .insert("playlist".into(), BTreeMap::from([("track".into(), 28)])); + assert!(settings.validate().is_ok()); + settings + .playlist_column_widths + .insert("playlist".into(), BTreeMap::from([("track".into(), 27)])); + assert!(settings.validate().is_err()); + } + #[test] fn atomic_save_leaves_no_partial_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/apps/desktop/src-tauri/windows-app-manifest.xml b/apps/desktop/src-tauri/windows-app-manifest.xml new file mode 100644 index 0000000..2d510ed --- /dev/null +++ b/apps/desktop/src-tauri/windows-app-manifest.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 537288c..3628692 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -21,6 +21,8 @@ } :root { --accent: #3f7fd6; --gold: #e0a53d; --gel-border: #2a63b8; --gel-top: #7db4f2; --gel-bottom: #2f6fd0; } +html, body { font-family: "Lucida Grande", "Helvetica Neue", Helvetica, Arial, sans-serif; } +button, input, select, textarea { font: inherit; } button { color: inherit; } .app-shell { height: 100%; min-width: 0; color: var(--text); background: var(--desk); font-size: 11px; } @@ -142,6 +144,7 @@ button { color: inherit; } .track-row { min-width: 720px; height: 18px; display: grid; grid-template-columns: 16px minmax(160px, 1.6fr) 52px 1.1fr 1.1fr .9fr 84px; align-items: center; padding: 0 6px; white-space: nowrap; } .zebra .track-scroll .track-row:not(.track-header):nth-child(odd) { background: var(--alt); } .track-row > span { min-width: 0; padding: 0 3px; overflow: hidden; text-overflow: ellipsis; } +.track-row > span.meta { color: var(--dim); } .track-number { text-align: right; font-variant-numeric: tabular-nums; } .track-name { display: flex; align-items: center; gap: 4px; } .track-name .local-glyph { flex: 0 0 auto; color: var(--dim); font-size: 11px; } @@ -180,7 +183,9 @@ button { color: inherit; } .spotify-results h2 { margin: 0; padding: 8px 12px 4px; font-size: 12px; } .spotify-results p { margin: 8px 12px; color: var(--dim); } .spotify-row { width: 100%; min-height: 44px; display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 4px 10px 4px 12px; border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, var(--list)); color: var(--text); background: var(--list); } +.spotify-search-row { grid-template-columns: 36px minmax(0, 1fr) 250px; } .spotify-row:hover { background: var(--alt); } +.spotify-search-row[aria-current="true"] { font-weight: 700; } .spotify-artwork { width: 36px; height: 36px; display: grid; place-items: center; overflow: hidden; color: var(--dim); background: var(--control); font-size: 18px; } .spotify-artwork.round { border-radius: 50%; } .spotify-artwork img { width: 100%; height: 100%; object-fit: cover; } @@ -190,6 +195,16 @@ button { color: inherit; } .spotify-link { padding: 4px 0; border: 0; color: var(--accent); background: transparent; } .spotify-row time { color: var(--dim); } .spotify-add:disabled, .spotify-link:disabled { opacity: .65; } +.spotify-result-actions { min-width: 0; display: grid; grid-template-columns: 54px 28px 68px 16px; align-items: center; gap: 6px; } +.spotify-result-meta { min-width: 0; overflow: hidden; color: var(--dim); text-align: right; text-overflow: ellipsis; white-space: nowrap; } +.spotify-result-empty { display: block; width: 100%; height: 24px; } +.spotify-play-round { width: 24px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 50%; color: var(--text); background: var(--control); font-size: 10px; line-height: 22px; } +.spotify-result-actions .spotify-add { min-width: 68px; padding: 3px 6px; border-radius: 12px; } +.spotify-result-view { width: 16px; padding: 3px 0; border: 0; color: var(--dim); background: transparent; text-align: center; } +.spotify-result-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.spotify-result-actions button:disabled { opacity: .55; } +.spotify-search-loading, .spotify-search-error, .spotify-search-more { display: flex; align-items: center; gap: 8px; padding: 6px 12px; color: var(--dim); } +.spotify-search-error button, .spotify-search-more button { padding: 3px 9px; border: 1px solid var(--border); border-radius: 12px; color: var(--text); background: var(--control); } .spotify-page { min-height: 0; flex: 1; display: flex; flex-direction: column; overflow-y: auto; background: var(--list); } .spotify-page-back { width: 100%; padding: 9px 16px; border: 0; border-bottom: 1px solid var(--border); color: var(--accent); background: var(--list); text-align: left; font-size: 12px; } .spotify-page-header { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 13px; padding: 13px 15px 17px; border-bottom: 1px solid var(--border); } @@ -264,6 +279,7 @@ button { color: inherit; } .playlist-track-header, .playlist-track-row { min-width: 620px; display: grid; grid-template-columns: 35px minmax(160px, 1.5fr) 55px minmax(110px, 1fr) minmax(110px, 1fr); align-items: center; padding: 0 9px; } .playlist-track-header { height: 20px; flex: 0 0 20px; border-bottom: 1px solid var(--border); color: var(--dim); background: linear-gradient(var(--head1), var(--head2)); font-size: 10px; } .playlist-track-header > button { min-width: 0; padding: 0 3px; overflow: hidden; border: 0; color: inherit; background: transparent; font: inherit; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.playlist-track-header > button[data-column] { position: relative; overflow: visible; } .playlist-track-header > button:first-child, .playlist-track-header > button.track-number { text-align: right; } .playlist-track-header > button.active { color: var(--text); font-weight: 700; } .playlist-track-scroll { min-height: 0; flex: 1; overflow: auto; } @@ -299,7 +315,7 @@ button { color: inherit; } .modal-actions .primary { border-color: var(--accent); color: #fff; background: var(--accent); } .modal-actions .danger { border-color: var(--danger); color: #fff; background: var(--danger); } .modal-actions button:disabled { opacity: .5; } -.preferences { width: min(556px, calc(100vw - 40px)); padding: 0; overflow: hidden; background: var(--control); } +.preferences { width: min(556px, calc(100vw - 40px)); max-height: calc(100vh - 40px); display: flex; flex-direction: column; padding: 0; overflow: hidden; background: var(--control); } .preferences h2 { height: 34px; box-sizing: border-box; margin: 0; padding: 9px 16px; border-bottom: 1px solid var(--border); background: linear-gradient(var(--head1), var(--head2)); text-align: center; font-size: 12.5px; } .preference-toolbar { display: flex; justify-content: center; gap: 6px; padding: 9px 14px 10px; border-bottom: 1px solid var(--border); background: linear-gradient(var(--head2), var(--control)); } .preference-toolbar button { width: 76px; display: flex; flex-direction: column; align-items: center; gap: 1px; padding: 5px 0 4px; border: 0; border-radius: 6px; color: var(--dim); background: transparent; font: inherit; font-size: 10.5px; } @@ -307,16 +323,19 @@ button { color: inherit; } .preference-toolbar button.active { color: var(--text); background: rgb(0 0 0 / .1); box-shadow: inset 0 1px 2px rgb(0 0 0 / .2); font-weight: 600; } :root[data-theme="dark"] .preference-toolbar button.active { background: rgb(255 255 255 / .14); } .preference-toolbar button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } -.preference-content { min-height: 310px; padding: 18px 22px 16px; box-sizing: border-box; } +.preference-content { min-height: 0; height: 310px; flex: 1 1 310px; padding: 18px 22px 16px; overflow-y: auto; box-sizing: border-box; } .preference-group { margin: 0 0 13px; } .preference-group:last-child { margin-bottom: 0; } -.preference-group h3 { display: flex; justify-content: space-between; align-items: baseline; margin: 0 0 5px; font-size: 11.5px; } +.preference-group h3 { display: flex; justify-content: space-between; align-items: baseline; margin: 0 0 5px; font-size: 11px; } .preference-group h3 small { color: var(--dim); font-size: 10.5px; font-weight: 400; } .preference-inset { padding: 10px 12px; border: 1px solid var(--border); border-radius: 5px; background: var(--list); } .preference-options { display: grid; gap: 8px; } .preference-row { display: flex; align-items: center; gap: 24px; } +.preference-size { padding-top: 8px; padding-bottom: 7px; } +.preferences .preference-range { width: 100%; height: 16px; margin: 0; padding: 0; border: 0; accent-color: var(--accent); background: transparent; } +.range-labels { display: flex; justify-content: space-between; color: var(--dim); font-size: 9px; } .preference-choice { display: flex; align-items: flex-start; gap: 8px; margin: 0; cursor: default; } -.preference-choice > span { min-width: 0; font-size: 12px; } +.preference-choice > span { min-width: 0; font-size: 11px; } .preference-choice strong { display: block; font-weight: 500; } .preference-choice small { display: block; margin-top: 2px; color: var(--dim); font-size: 10.5px; line-height: 1.25; } .preference-choice input { appearance: none; display: grid; place-content: center; flex: 0 0 auto; width: 15px; height: 15px; margin: 1px 0 0; padding: 0; border: 1px solid var(--border); background: linear-gradient(var(--list), var(--control)); box-shadow: inset 0 1px 1px rgb(0 0 0 / .16), 0 1px 0 rgb(255 255 255 / .6); } @@ -334,9 +353,48 @@ button { color: inherit; } .quality-options { justify-content: space-between; } .threshold-options { gap: 20px; } .client-id-field label { display: flex; align-items: center; gap: 9px; } -.client-id-field label > span { width: 64px; color: var(--dim); text-align: right; font-size: 12px; } +.client-id-field label > span { width: 64px; color: var(--dim); text-align: right; font-size: 11px; } .client-id-field input { flex: 1; min-width: 0; height: 24px; box-sizing: border-box; padding: 0 8px; border: 1px solid var(--border); border-radius: 4px; color: var(--text); background: var(--list); box-shadow: inset 0 1px 2px rgb(0 0 0 / .1); } .client-id-field > small { display: block; padding-left: 73px; margin-top: 6px; color: var(--dim); font-size: 10.5px; } +.lastfm-section { display: flex; flex-direction: column; gap: 5px; } +.lastfm-card { display: flex; flex-direction: column; gap: 10px; padding: 11px 12px; } +.lastfm-status-row { min-height: 22px; display: flex; align-items: center; gap: 9px; } +.lastfm-status-dot { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 50%; background: var(--border); } +.lastfm-status-dot.connected { background: #28c840; box-shadow: 0 0 0 3px rgb(40 200 64 / .2); } +.lastfm-status-dot.pending { background: var(--dim); } +.lastfm-status-label { min-width: 0; font-size: 11px; } +.lastfm-status-copy { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 2px; } +.lastfm-status-copy.muted { color: var(--dim); } +.lastfm-status-copy small { color: var(--dim); font-size: 10.5px; line-height: 1.25; } +.lastfm-pill { height: 22px; flex: 0 0 auto; padding: 0 12px; border: 1px solid var(--border); border-radius: 11px; color: var(--text); background: linear-gradient(var(--list), var(--control)); font: inherit; font-size: 11px; cursor: default; } +.lastfm-pill.primary { border-color: var(--gel-border); color: #fff; background: linear-gradient(#8fc0f5, var(--accent) 52%, var(--gel-bottom)); box-shadow: inset 0 1px 0 rgb(255 255 255 / .55); font-weight: 600; } +.lastfm-pill:disabled { opacity: .5; } +.lastfm-unavailable { margin: 0; color: var(--dim); line-height: 1.35; } +.lastfm-attribution { display: block; color: var(--dim); font-size: 10.5px; line-height: 1.25; } +.lastfm-attribution a { color: inherit; text-decoration: none; } +.lastfm-attribution a:hover, .lastfm-attribution a:focus-visible { text-decoration: underline; } +.diagnostic-card { padding: 0; overflow: hidden; } +.diagnostic-filters { min-height: 32px; display: flex; align-items: center; gap: 15px; padding: 0 10px; border-bottom: 1px solid var(--border); } +.diagnostic-filters .preference-choice { align-items: center; gap: 5px; } +.diagnostic-filters .preference-choice > span { font-size: 10.5px; } +.diagnostic-filters .preference-choice small { display: inline; margin: 0; font-size: 9px; } +.diagnostic-filters > button { margin-left: auto; padding: 0; border: 0; color: var(--accent); background: transparent; font: inherit; font-size: 10.5px; text-decoration: underline; } +.diagnostic-log { height: 158px; overflow: auto; background: var(--list); font-size: 10.5px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.diagnostic-entry { display: grid; grid-template-columns: max-content 38px max-content minmax(180px, 1fr); gap: 9px; padding: 3px 7px; border-bottom: 1px solid color-mix(in srgb, var(--border) 35%, transparent); } +.diagnostic-entry:nth-child(even) { background: color-mix(in srgb, var(--control) 45%, transparent); } +.diagnostic-entry.warn { background: color-mix(in srgb, #e6a817 15%, var(--list)); } +.diagnostic-entry.error { background: color-mix(in srgb, #d84848 16%, var(--list)); } +.diagnostic-entry strong { font-weight: 600; } +.diagnostic-empty { display: grid; place-items: center; height: 100%; color: var(--dim); } +.diagnostic-report { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; } +.diagnostic-report > div:first-child { min-width: 0; flex: 1; } +.diagnostic-report strong, .diagnostic-report small { display: block; } +.diagnostic-report small { margin-top: 3px; color: var(--dim); font-size: 10.5px; line-height: 1.25; } +.diagnostic-actions { display: flex; flex: 0 0 auto; gap: 7px; } +.diagnostic-actions button { height: 23px; padding: 0 12px; border: 1px solid var(--border); border-radius: 12px; color: var(--text); background: linear-gradient(var(--list), var(--control)); font: inherit; font-size: 10.5px; } +.diagnostic-actions button.primary { border-color: var(--gel-border); color: #fff; background: linear-gradient(#8fc0f5, var(--accent) 52%, var(--gel-bottom)); } +.diagnostic-actions button:disabled { opacity: .5; } +.diagnostic-report > .error-text, .diagnostic-report > small { flex-basis: 100%; } .preferences > .modal-actions { margin-top: 0; padding: 14px 18px 16px; border-top: 1px solid var(--border); background: linear-gradient(var(--control), var(--head2)); } .preferences > .modal-actions button { min-width: 0; height: 25px; padding: 0 15px; border-radius: 12px; background: linear-gradient(var(--list), var(--control)); } .preferences > .modal-actions .primary { padding: 0 18px; border-color: var(--gel-border); background: linear-gradient(#8fc0f5, var(--accent) 52%, var(--gel-bottom)); box-shadow: inset 0 1px 0 rgb(255 255 255 / .55); } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 3fbabe8..984b7d1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -3,11 +3,12 @@ import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { Fragment, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import './App.css' -import { browseRequestKey, browseViewForRequest, COLUMN_SPECS, compareTracks, contiguousRange, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentTrack, labels, moveToIndex, nextNativeDragActive, normalizeZoom, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistRows, replacementQueue, selectionAfterFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns } from './ui.ts' +import { appliedZoom, browseRequestKey, browseViewForRequest, COLUMN_SPECS, compareTracks, contiguousRange, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentTrack, labels, moveBefore, moveToIndex, nextNativeDragActive, normalizeZoom, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, PLAYLIST_COLUMNS, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, resizedColumnWidth, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns, visibleColumnOrder } from './ui.ts' +import { defaultSettings, initialState, reducer, type Action, type State } from './appState.ts' import { GetInfo, MultipleItemInformation, PlaybackAuthorization, Preferences, SetupLibrary } from './dialogViews.tsx' import { AlbumRatingStrip, BrowserPane, TrackCell, TrackList } from './libraryViews.tsx' -import { SpotifySearch } from './spotifyViews.tsx' -import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, ConnectionState, ImportSummary, InfoDialog, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayOutcome, PlayerState, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults, Theme, Track, TrackInfo } from './types.ts' +import { SpotifyPageBack, SpotifySearch } from './spotifyViews.tsx' +import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, ConnectionState, ImportSummary, LastFmState, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayOutcome, PlayerState, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults, Theme, Track, TrackInfo } from './types.ts' import { CheckboxMenu, ContextMenu, ModalDialog } from './viewShared.tsx' const LOCAL_PLAYLIST_HINT = "Selection includes local files — Spotify playlists can't contain them." @@ -15,245 +16,11 @@ const LOCAL_PLAYLIST_HINT = "Selection includes local files — Spotify playlist const emptyTracks: Track[] = [] const ZOOM_MIN = 0.7 const ZOOM_MAX = 1.8 +const ZOOM_BASE = 1.15 // Settings persisted by set_repeat / set_audio_settings — excluded from the // generic settings-save effect (set_settings also switches playback backends). const EXCLUDED = ['repeat', 'streamingBitrate', 'normalizeVolume', 'gapless'] as const -type State = { - source: Source - sel: Selection - query: string - scope: 'library' | 'spotify' - selectedTrackIds: Set - selectionAnchor?: number - playing: Playing | null - settings: Settings - settingsHydrated: boolean - systemDark: boolean - view: BrowseView | null - viewKey?: string - revision: number - error?: string - notice?: string - info?: InfoDialog - preferences: boolean - setup: boolean - playbackAuthorization: PlaybackAuthorizationPrompt | null - connection: ConnectionState - spotifyResults: SpotifyResults | null - spotifySearching: boolean - spotifyNavigation?: SpotifyNavEntry - selectedPlaylist?: string - playlistRevision: number - syncPhase?: string - syncProgress?: { tracks: number; fraction: number } - importStatus?: string -} - -type Action = - | { type: 'view'; view: BrowseView; key: string } - | { type: 'error'; error: string } - | { type: 'clear-error' } - | { type: 'source'; source: Source } - | { type: 'playlist'; id?: string } - | { type: 'select'; facet: keyof Selection; values: string[] } - | { type: 'query'; query: string } - | { type: 'scope'; scope: State['scope'] } - | { type: 'selectTrack'; id: number } - | { type: 'selection'; ids: Set; anchor?: number } - | { type: 'play'; id: number; queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } - | { type: 'queue'; queue: readonly PlaybackTrack[]; origin: PlaybackOrigin } - | { type: 'togglePlay' } - | { type: 'step'; id: number } - | { type: 'tick'; duration: number; nextId: number } - | { type: 'seek'; elapsed: number } - | { type: 'playerState'; player: PlayerState; queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } - | { type: 'hydrateSettings'; settings: Settings } - | { type: 'settings'; settings: Partial } - | { type: 'browserPanes'; browserPanes: BrowserPanes } - | { type: 'systemTheme'; dark: boolean } - | { type: 'refresh' } - | { type: 'notice'; notice?: string } - | { type: 'info'; info?: InfoDialog } - | { type: 'preferences'; open: boolean } - | { type: 'setup'; open: boolean } - | { type: 'playbackAuthorization'; prompt: PlaybackAuthorizationPrompt | null } - | { type: 'connection'; connection: ConnectionState } - | { type: 'spotifyResults'; results: SpotifyResults | null } - | { type: 'spotifySearching'; searching: boolean } - | { type: 'spotifyNavigate'; entry: SpotifyNavEntry } - | { type: 'syncPhase'; phase?: string } - | { type: 'syncProgress'; progress: { tracks: number; fraction: number } } - | { type: 'importStarted' } - | { type: 'importComplete'; summary: ImportSummary } - | { type: 'importFailed' } - | { type: 'clearImportStatus' } - | { type: 'playlistsRefresh' } - -const defaultSettings: Settings = { - theme: 'system', - zoom: 1, - zebra: true, - plCollapsed: false, - browserVisible: true, - browserPanes: { cat: true, art: true, alb: true }, - // Mirrors Settings::default() in src-tauri/src/store.rs — keep in sync - columnOrder: ['name', 'artist', 'album', 'disc', 'track', 'time', 'rating', 'genre', 'plays', 'kind', 'bitrate', 'lastPlayed', 'added', 'releaseDate'], - columnWidths: {}, - hiddenColumns: ['disc', 'kind', 'bitrate', 'lastPlayed', 'added', 'releaseDate'], - playlistHiddenColumns: {}, - sortColumn: null, - sortDesc: false, - autoAddSpotifyLibrary: true, - autoConnect: true, - spotifyClientId: '', - spotifySyncCompleted: false, - playbackBackend: 'local', - repeat: 'off', - shuffle: false, - volume: 62, - streamingBitrate: 320, - normalizeVolume: false, - gapless: true, - playThresholdPercent: 100, -} - -const initialState: State = { - source: 'music', - sel: {}, - query: '', - scope: 'library', - selectedTrackIds: new Set(), - playing: null, - settings: defaultSettings, - settingsHydrated: false, - systemDark: false, - view: null, - viewKey: undefined, - revision: 0, - preferences: false, - setup: false, - playbackAuthorization: null, - connection: { connected: false, needs_reauth: false, playback_authorized: false }, - spotifyResults: null, - spotifySearching: false, - playlistRevision: 0, -} - -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'view': - return { ...state, view: action.view, viewKey: action.key, error: undefined } - case 'error': - return { ...state, error: action.error, syncProgress: undefined } - case 'clear-error': - return { ...state, error: undefined } - case 'source': - return { ...state, source: action.source, sel: {}, query: '', spotifyNavigation: undefined, selectedPlaylist: undefined, selectedTrackIds: new Set(), selectionAnchor: undefined } - case 'playlist': - return { ...state, selectedPlaylist: action.id, spotifyNavigation: undefined, sel: {}, selectedTrackIds: new Set(), selectionAnchor: undefined } - case 'select': { - const sel = selectionAfterFacet(state.sel, action.facet, action.values) - return { ...state, sel, selectedTrackIds: new Set(), selectionAnchor: undefined } - } - case 'query': - return { ...state, query: action.query, spotifyNavigation: undefined, selectedTrackIds: new Set(), selectionAnchor: undefined } - case 'scope': - return { ...state, scope: action.scope, spotifyNavigation: undefined } - case 'selectTrack': - return { ...state, selectedTrackIds: new Set([action.id]), selectionAnchor: action.id } - case 'selection': - return { ...state, selectedTrackIds: action.ids, selectionAnchor: action.anchor } - case 'play': - return { - ...state, - selectedTrackIds: new Set([action.id]), - selectionAnchor: action.id, - playing: { - trackId: action.id, elapsed: 0, isPlaying: true, queue: action.queue, - uri: action.queue.find((track) => track.id === action.id)?.uri ?? null, - external: false, name: null, art: null, alb: null, durationSecs: null, - shuffle: state.settings.shuffle, origin: action.origin, simulated: true, - }, - } - case 'queue': - return state.playing - ? { ...state, playing: { ...state.playing, queue: action.queue, origin: action.origin } } - : state - case 'togglePlay': - return state.playing - ? { ...state, playing: { ...state.playing, isPlaying: !state.playing.isPlaying } } - : state - case 'step': - return state.playing - ? { ...state, playing: { ...state.playing, trackId: action.id, elapsed: 0, isPlaying: true } } - : state - case 'tick': - if (!state.playing?.isPlaying) return state - return state.playing.elapsed + 1 >= action.duration - ? { ...state, playing: { ...state.playing, trackId: action.nextId, elapsed: 0, isPlaying: true } } - : { ...state, playing: { ...state.playing, elapsed: state.playing.elapsed + 1 } } - case 'playerState': - return action.player.trackId === null && !action.player.name - ? { ...state, playing: null } - : { - ...state, - playing: { ...action.player, queue: action.player.external ? emptyTracks : action.queue, origin: action.origin }, - } - case 'seek': - return state.playing - ? { ...state, playing: { ...state.playing, elapsed: action.elapsed } } - : state - case 'hydrateSettings': - return { ...state, settings: action.settings, settingsHydrated: true } - case 'settings': - return { ...state, settings: { ...state.settings, ...action.settings } } - case 'browserPanes': { - const sel = { ...state.sel } - for (const facet of ['cat', 'art', 'alb'] as const) if (!action.browserPanes[facet]) delete sel[facet] - return { ...state, sel, settings: { ...state.settings, browserPanes: action.browserPanes } } - } - case 'systemTheme': - return { ...state, systemDark: action.dark } - case 'refresh': - return { ...state, revision: state.revision + 1 } - case 'notice': - return { ...state, notice: action.notice } - case 'info': - return { ...state, info: action.info, preferences: false, setup: false } - case 'preferences': - return { ...state, preferences: action.open, setup: false, info: undefined } - case 'setup': - return { ...state, setup: action.open, preferences: false, info: undefined } - case 'playbackAuthorization': - return action.prompt - ? { ...state, playbackAuthorization: action.prompt, info: undefined, preferences: false, setup: false } - : { ...state, playbackAuthorization: null } - case 'connection': - return { ...state, connection: action.connection, playbackAuthorization: action.connection.playback_authorized ? null : state.playbackAuthorization } - case 'spotifyResults': - return { ...state, spotifyResults: action.results, spotifySearching: false } - case 'spotifySearching': - return { ...state, spotifySearching: action.searching } - case 'spotifyNavigate': - return { ...state, scope: 'spotify', query: '', spotifyNavigation: action.entry, selectedPlaylist: undefined } - case 'syncPhase': - return { ...state, syncPhase: action.phase, syncProgress: action.phase ? state.syncProgress : undefined } - case 'syncProgress': - return { ...state, syncProgress: action.progress.fraction < 1 ? action.progress : undefined } - case 'importStarted': - return { ...state, importStatus: 'Importing local files…' } - case 'importComplete': - return { ...state, importStatus: `Imported ${action.summary.imported} tracks (${action.summary.duplicates} duplicates skipped, ${action.summary.failed.length} failed)` } - case 'importFailed': - return { ...state, importStatus: undefined } - case 'clearImportStatus': - return { ...state, importStatus: undefined } - case 'playlistsRefresh': - return { ...state, playlistRevision: state.playlistRevision + 1 } - } -} - function useTauriEvent(event: string, handler: (payload: T) => void) { const ref = useRef(handler) @@ -325,24 +92,6 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla dispatch({ type: 'play', id, queue: playable, origin: launchOrigin }) }, [connected, dispatch, run]) - const replace = useCallback((tracks: readonly PlaybackTrack[], nextOrigin: PlaybackOrigin) => { - if (starting.current) return - const replacement = replacementQueue(tracks, playingRef.current) - if (!replacement) return - if (playingRef.current?.simulated) { - dispatch({ type: 'queue', queue: replacement.queue, origin: nextOrigin }) - return - } - invoke('replace_queue', { snapshot: replacement.queue, currentIndex: replacement.index }) - .then((outcome) => { - if (playbackAuthorizationPrompt(outcome)) return - queue.current = replacement.queue - origin.current = nextOrigin - dispatch({ type: 'queue', queue: replacement.queue, origin: nextOrigin }) - }) - .catch((error) => dispatch({ type: 'error', error: String(error) })) - }, [dispatch]) - useEffect(() => { if (!connected || !pendingPlay.current) return if (!playbackRetryReady(connected, playbackAuthorized, pendingPlay.current.awaitingPlaybackAuthorization)) return @@ -391,7 +140,7 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla useEffect(() => () => window.clearTimeout(volumeTimer.current), []) - return useMemo(() => ({ start, replace, toggle, step, setVolume, seek, cancelPending }), [cancelPending, replace, seek, setVolume, start, step, toggle]) + return useMemo(() => ({ start, toggle, step, setVolume, seek, cancelPending }), [cancelPending, seek, setVolume, start, step, toggle]) } function App() { @@ -448,9 +197,6 @@ function App() { const first = displayedTracks[0] if (first && tracklistVisible) player.start(first.id, displayedTracks, { kind: 'library', source: state.source }) }, [browserPlayKey, browseKey, displayedTracks, player, state.source, state.viewKey, tracklistVisible]) - useEffect(() => { - if (!browserPlayKey && tracklistVisible) player.replace(displayedTracks, { kind: 'library', source: state.source }) - }, [browserPlayKey, displayedTracks, player, state.playing?.queue, state.source, tracklistVisible]) const setBrowserPanes = useCallback((browserPanes: BrowserPanes) => { for (const facet of ['cat', 'art', 'alb'] as const) { if (!browserPanes[facet]) delete facetAnchors.current[facet] @@ -484,10 +230,18 @@ function App() { source: state.source, sel: { cat: state.sel.cat ?? [], art: state.sel.art ?? [], alb: state.sel.alb ?? [] }, query: state.scope === 'library' && state.query.trim() ? state.query : undefined, - }).then((next) => active && dispatch({ type: 'view', view: next, key: requestKey })) + }).then((next) => { + if (!active) return + const fallback = staleSelectionFacet(state.sel, next.facets) + if (fallback) { + selectFacet(fallback, []) + return + } + dispatch({ type: 'view', view: next, key: requestKey }) + }) .catch((error) => active && fail(error)) return () => { active = false } - }, [state.source, state.sel, state.query, state.scope, state.revision, fail]) + }, [state.source, state.sel, state.query, state.scope, state.revision, fail, selectFacet]) useEffect(() => { let active = true @@ -513,6 +267,9 @@ function App() { invoke('connection_state') .then((connection) => dispatch({ type: 'connection', connection })) .catch(fail) + invoke('lastfm_state') + .then((lastfm) => dispatch({ type: 'lastfm', lastfm })) + .catch(fail) }, [fail]) const saveKey = useMemo( @@ -535,6 +292,7 @@ function App() { useTauriEvent('operation-error', (error) => dispatch({ type: 'error', error })) useTauriEvent('operation-recovered', () => dispatch({ type: 'clear-error' })) useTauriEvent('connection-changed', (connection) => dispatch({ type: 'connection', connection })) + useTauriEvent('lastfm-changed', (lastfm) => dispatch({ type: 'lastfm', lastfm })) useTauriEvent('settings-changed', (settings) => dispatch({ type: 'hydrateSettings', settings })) useTauriEvent('sync-progress', (phase) => dispatch({ type: 'syncPhase', phase: phase || undefined })) useTauriEvent<{ tracks: number; fraction: number }>('sync-progress-count', (progress) => dispatch({ type: 'syncProgress', progress })) @@ -567,7 +325,7 @@ function App() { dispatch({ type: 'spotifySearching', searching: true }) let active = true const timer = window.setTimeout(() => { - invoke('spotify_search', { query }) + invoke('spotify_search', { query, offset: 0 }) .then((results) => active && dispatch({ type: 'spotifyResults', results })) .catch((error) => { if (!active) return @@ -757,9 +515,7 @@ function App() { }, [playlistSubject, state.info, state.playbackAuthorization, state.preferences, state.setup, state.settings.zoom]) const selectedPlaylist = playlists?.find((playlist) => playlist.id === state.selectedPlaylist) - const playlistHiddenColumns = selectedPlaylist - ? state.settings.playlistHiddenColumns[selectedPlaylist.id] ?? defaultSettings.hiddenColumns - : defaultSettings.hiddenColumns + const playlistLayout = playlistLayoutFor(selectedPlaylist?.id, state.settings) const showPlayingOrigin = () => { const origin = state.playing?.origin if (!origin) return @@ -769,7 +525,7 @@ function App() { } return ( -
+
invoke('add_spotify_album', album) .catch((error) => { fail(error); throw error })} + onAddTrack={(uri) => invoke('add_spotify_track', { uri }) + .catch((error) => { fail(error); throw error })} + onRemoveTrack={(uri) => invoke('remove_spotify_track', { uri }) + .catch((error) => { fail(error); throw error })} onPlay={player.start} onPlaylist={setPlaylistSubject} onClose={() => dispatch({ type: 'scope', scope: 'library' })} @@ -823,12 +583,14 @@ function App() { /> :
Connect to Spotify to search artists and albums.
) : selectedPlaylist ? dispatch({ type: 'playlist' })} onPlay={(id, tracks) => player.start(id, tracks, { kind: 'playlist', id: selectedPlaylist.id })} onRate={(id, stars) => mutate('click_track_star', { id, stars })} onOpen={(target) => invoke('open_spotify_playlist', { id: selectedPlaylist.id, target }).catch(fail)} @@ -837,13 +599,18 @@ function App() { if (tracks.length > 1 || tracks[0]?.id === null) dispatch({ type: 'info', info: { kind: 'multiple', tracks } }) else openInfo(tracks[0]?.id) }} + onReorder={(columnOrder) => dispatch({ + type: 'settings', + settings: { playlistColumnOrders: playlistOverride(state.settings.playlistColumnOrders, selectedPlaylist.id, columnOrder, PLAYLIST_DEFAULT_COLUMN_ORDER) }, + })} + onColumnWidths={(columnWidths) => dispatch({ + type: 'settings', + settings: { playlistColumnWidths: playlistOverride(state.settings.playlistColumnWidths, selectedPlaylist.id, columnWidths, {}) }, + })} onHiddenColumns={(hiddenColumns) => dispatch({ type: 'settings', settings: { - playlistHiddenColumns: { - ...state.settings.playlistHiddenColumns, - [selectedPlaylist.id]: hiddenColumns, - }, + playlistHiddenColumns: playlistOverride(state.settings.playlistHiddenColumns, selectedPlaylist.id, hiddenColumns, PLAYLIST_DEFAULT_HIDDEN_COLUMNS), }, })} onError={(error) => dispatch({ type: 'error', error })} @@ -930,7 +697,7 @@ function App() { return invoke('sync_from_spotify') }) .catch(fail)} />} - {state.preferences && { + {state.preferences && dispatch({ type: 'lastfm', lastfm })} onSave={({ browserPanes, ...settings }) => { const audioChanged = settings.streamingBitrate !== state.settings.streamingBitrate || settings.normalizeVolume !== state.settings.normalizeVolume || settings.gapless !== state.settings.gapless @@ -1239,19 +1006,23 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse {confirming && setConfirming(undefined)} onSubmit={busy ? undefined : unfollow} closeOnBackdrop>

{confirming.owned ? 'Delete Playlist?' : 'Unfollow Playlist?'}

{confirming.owned ? `Delete “${confirming.name}” from Spotify?` : `Stop following “${confirming.name}”?`}

} } -function PlaylistView({ playlist, revision, libraryRevision, playing, columnOrder, columnWidths, hiddenColumns, onPlay, onRate, onOpen, onPlaylist, onInfo, onHiddenColumns, onError }: { +function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, columnOrder, columnWidths, hiddenColumns, onBack, onPlay, onRate, onOpen, onPlaylist, onInfo, onReorder, onColumnWidths, onHiddenColumns, onError }: { playlist: PlaylistListView + backLabel: string revision: number libraryRevision: number playing: State['playing'] columnOrder: ColumnKey[] columnWidths: Partial> hiddenColumns: ColumnKey[] + onBack: () => void onPlay: (id: number, tracks: readonly PlaybackTrack[]) => void onRate: (id: number, stars: number) => void onOpen: (target: 'app' | 'web') => void onPlaylist: (subject: PlaylistSubject) => void onInfo: (tracks: PlaylistTrack[]) => void + onReorder: (order: ColumnKey[]) => void + onColumnWidths: (widths: Partial>) => void onHiddenColumns: (columns: ColumnKey[]) => void onError: (error: string) => void }) { @@ -1262,14 +1033,19 @@ function PlaylistView({ playlist, revision, libraryRevision, playing, columnOrde const [mutating, setMutating] = useState(false) const [sortColumn, setSortColumn] = useState(null) const [sortDesc, setSortDesc] = useState(false) + const [liveWidths, setLiveWidths] = useState(columnWidths) const [menu, setMenu] = useState<{ x: number; y: number; upstreamIndex?: number }>() const onErrorRef = useRef(onError) const trackDrag = useRef<{ indices: number[]; pointerId: number; startY: number; moved: boolean } | undefined>(undefined) const dragInsertBefore = useRef(undefined) const suppressTrackClick = useRef(false) + const headerDragged = useRef(false) + const columnDrag = useRef<{ column: ColumnKey; pointerId: number; startX: number; element: HTMLButtonElement } | undefined>(undefined) + const resize = useRef<{ column: ColumnKey; pointerId: number; startX: number; startWidth: number } | undefined>(undefined) onErrorRef.current = onError const canChangePlaylist = playlist.owned && tracks.length === playlist.trackCount const canReorder = canChangePlaylist && sortColumn === null + useEffect(() => setLiveWidths(columnWidths), [columnWidths]) useEffect(() => { if (!playlist.itemsAvailable) { setTracks([]) @@ -1300,8 +1076,53 @@ function PlaylistView({ playlist, revision, libraryRevision, playing, columnOrde const rows = playlistRows(tracks, sortColumn, sortDesc) const queue: PlaybackTrack[] = rows.map(({ track, upstreamIndex }) => ({ ...track, id: track.id ?? SYNTHETIC_BASE + upstreamIndex })) const headings = { ...trackColumnHeadings(labels.music), track: 'Track' } - const visibleColumns = columnOrder.filter((column) => !hiddenColumns.includes(column)) - const columns = trackGridColumns(visibleColumns, columnWidths, '35px') + const customizableColumns = columnOrder.filter((column) => PLAYLIST_COLUMNS.includes(column)) + const visibleColumns = visibleColumnOrder(customizableColumns, hiddenColumns) + const columns = trackGridColumns(visibleColumns, liveWidths, '35px') + const moveColumn = (event: React.PointerEvent) => { + const active = columnDrag.current + if (!active || active.pointerId !== event.pointerId) return + if (Math.abs(event.clientX - active.startX) > 4) { + headerDragged.current = true + active.element.classList.add('dragging') + } + } + const endColumn = (event: React.PointerEvent) => { + const active = columnDrag.current + if (!active || active.pointerId !== event.pointerId) return + active.element.classList.remove('dragging') + columnDrag.current = undefined + if (!headerDragged.current) return + const target = visibleColumns.find((column) => { + const element = event.currentTarget.parentElement?.querySelector(`[data-column="${column}"]`) + return element != null && event.clientX < element.getBoundingClientRect().left + element.getBoundingClientRect().width / 2 + }) + onReorder(moveBefore(columnOrder, active.column, target)) + } + const beginResize = (event: React.PointerEvent, column: ColumnKey) => { + event.preventDefault() + event.stopPropagation() + headerDragged.current = true + event.currentTarget.setPointerCapture(event.pointerId) + resize.current = { column, pointerId: event.pointerId, startX: event.clientX, startWidth: event.currentTarget.parentElement?.getBoundingClientRect().width ?? 60 } + } + const moveResize = (event: React.PointerEvent) => { + const active = resize.current + if (!active || active.pointerId !== event.pointerId) return + setLiveWidths((widths) => ({ ...widths, [active.column]: resizedColumnWidth(active.startWidth, active.startX, event.clientX) })) + } + const endResize = (event: React.PointerEvent) => { + const active = resize.current + if (!active || active.pointerId !== event.pointerId) return + const width = resizedColumnWidth(active.startWidth, active.startX, event.clientX) + resize.current = undefined + onColumnWidths({ ...columnWidths, [active.column]: width }) + } + const cancelResize = (event: React.PointerEvent) => { + if (resize.current?.pointerId !== event.pointerId) return + resize.current = undefined + setLiveWidths(columnWidths) + } const select = (upstreamIndex: number, event: React.MouseEvent) => { if (event.shiftKey && selectionAnchor !== undefined) { const anchor = rows.findIndex((row) => row.upstreamIndex === selectionAnchor) @@ -1387,6 +1208,7 @@ function PlaylistView({ playlist, revision, libraryRevision, playing, columnOrde onInfo(chosen) } return
+
{playlist.name}{playlist.trackCount} {playlist.trackCount === 1 ? 'track' : 'tracks'}{playlist.owner ? ` · by ${playlist.owner}` : ''}{sortColumn ? ` · sorted by ${headings[sortColumn]}` : ''}{playlist.owned && }
{!playlist.itemsAvailable ?
Tracks unavailable in RetuneSpotify does not allow third-party apps to interact with playlists not owned by you. :-(
: <>
{ @@ -1395,11 +1217,26 @@ function PlaylistView({ playlist, revision, libraryRevision, playing, columnOrde setSelectionAnchor(undefined) }}>
{ event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY }) }}> - - {visibleColumns.map((column) => + {visibleColumns.map((column) => )} + }}>{headings[column]}{sortColumn === column ? sortDesc ? ' ▼' : ' ▲' : ''} beginResize(event, column)} onPointerMove={moveResize} onPointerUp={endResize} onPointerCancel={cancelResize} onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} onDragStart={(event) => { + event.preventDefault() + event.stopPropagation() + }} />)}
{rows.map(({ track, upstreamIndex }, rowIndex) =>
{upstreamIndex + 1}{visibleColumns.map((column) => )}
)} + >{upstreamIndex + 1}{visibleColumns.map((column) => )}
)} {canReorder &&
}
{menu && (menu.upstreamIndex === undefined - ? setMenu(undefined)} items={columnOrder.map((column) => ({ key: column, label: headings[column], checked: !hiddenColumns.includes(column), disabled: column === 'name', onChange: (checked) => onHiddenColumns(checked ? hiddenColumns.filter((hidden) => hidden !== column) : [...hiddenColumns, column]) }))} /> + ? setMenu(undefined)} items={customizableColumns.map((column) => ({ key: column, label: headings[column], checked: !hiddenColumns.includes(column), disabled: column === 'name', onChange: (checked) => onHiddenColumns(checked ? hiddenColumns.filter((hidden) => hidden !== column) : [...hiddenColumns, column]) }))} /> : setMenu(undefined)}> diff --git a/apps/desktop/src/appState.ts b/apps/desktop/src/appState.ts new file mode 100644 index 0000000..6bc0f69 --- /dev/null +++ b/apps/desktop/src/appState.ts @@ -0,0 +1,244 @@ +import { LIBRARY_DEFAULT_COLUMN_ORDER, LIBRARY_DEFAULT_HIDDEN_COLUMNS, rememberSelection, restoreSelection, selectionAfterFacet } from './ui.ts' +import type { BrowseView, BrowserPanes, ConnectionState, ImportSummary, InfoDialog, LastFmState, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayerState, Playing, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults } from './types.ts' + +const emptyTracks: PlaybackTrack[] = [] + +export type State = { + source: Source + sel: Selection + savedSelections: Record + query: string + scope: 'library' | 'spotify' + selectedTrackIds: Set + selectionAnchor?: number + playing: Playing | null + settings: Settings + settingsHydrated: boolean + systemDark: boolean + view: BrowseView | null + viewKey?: string + revision: number + error?: string + notice?: string + info?: InfoDialog + preferences: boolean + setup: boolean + playbackAuthorization: PlaybackAuthorizationPrompt | null + connection: ConnectionState + lastfm: LastFmState + spotifyResults: SpotifyResults | null + spotifySearching: boolean + spotifyNavigation?: SpotifyNavEntry + selectedPlaylist?: string + playlistRevision: number + syncPhase?: string + syncProgress?: { tracks: number; fraction: number } + importStatus?: string +} + +export type Action = + | { type: 'view'; view: BrowseView; key: string } + | { type: 'error'; error: string } + | { type: 'clear-error' } + | { type: 'source'; source: Source } + | { type: 'playlist'; id?: string } + | { type: 'select'; facet: keyof Selection; values: string[] } + | { type: 'query'; query: string } + | { type: 'scope'; scope: State['scope'] } + | { type: 'selectTrack'; id: number } + | { type: 'selection'; ids: Set; anchor?: number } + | { type: 'play'; id: number; queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } + | { type: 'togglePlay' } + | { type: 'step'; id: number } + | { type: 'tick'; duration: number; nextId: number } + | { type: 'seek'; elapsed: number } + | { type: 'playerState'; player: PlayerState; queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } + | { type: 'hydrateSettings'; settings: Settings } + | { type: 'settings'; settings: Partial } + | { type: 'browserPanes'; browserPanes: BrowserPanes } + | { type: 'systemTheme'; dark: boolean } + | { type: 'refresh' } + | { type: 'notice'; notice?: string } + | { type: 'info'; info?: InfoDialog } + | { type: 'preferences'; open: boolean } + | { type: 'setup'; open: boolean } + | { type: 'playbackAuthorization'; prompt: PlaybackAuthorizationPrompt | null } + | { type: 'connection'; connection: ConnectionState } + | { type: 'lastfm'; lastfm: LastFmState } + | { type: 'spotifyResults'; results: SpotifyResults | null } + | { type: 'spotifySearching'; searching: boolean } + | { type: 'spotifyNavigate'; entry: SpotifyNavEntry } + | { type: 'syncPhase'; phase?: string } + | { type: 'syncProgress'; progress: { tracks: number; fraction: number } } + | { type: 'importStarted' } + | { type: 'importComplete'; summary: ImportSummary } + | { type: 'importFailed' } + | { type: 'clearImportStatus' } + | { type: 'playlistsRefresh' } + +export const defaultSettings: Settings = { + theme: 'system', + zoom: 1, + zebra: true, + plCollapsed: false, + browserVisible: true, + browserPanes: { cat: true, art: true, alb: true }, + // Mirrors Settings::default() in src-tauri/src/store.rs — keep in sync + columnOrder: [...LIBRARY_DEFAULT_COLUMN_ORDER], + columnWidths: {}, + hiddenColumns: [...LIBRARY_DEFAULT_HIDDEN_COLUMNS], + playlistHiddenColumns: {}, + playlistColumnOrders: {}, + playlistColumnWidths: {}, + sortColumn: null, + sortDesc: false, + autoAddSpotifyLibrary: true, + autoConnect: true, + spotifyClientId: '', + spotifySyncCompleted: false, + playbackBackend: 'local', + repeat: 'off', + shuffle: false, + volume: 62, + streamingBitrate: 320, + normalizeVolume: false, + gapless: true, + playThresholdPercent: 100, + lastfmScrobbling: true, +} + +export const initialState: State = { + source: 'music', + sel: {}, + savedSelections: { music: {}, podcasts: {}, audiobooks: {} }, + query: '', + scope: 'library', + selectedTrackIds: new Set(), + playing: null, + settings: defaultSettings, + settingsHydrated: false, + systemDark: false, + view: null, + viewKey: undefined, + revision: 0, + preferences: false, + setup: false, + playbackAuthorization: null, + connection: { connected: false, needs_reauth: false, playback_authorized: false }, + lastfm: { available: false, connected: false, username: null, pending: false, reconnectRequired: false, problem: null }, + spotifyResults: null, + spotifySearching: false, + playlistRevision: 0, +} + +export function reducer(state: State, action: Action): State { + switch (action.type) { + case 'view': + return { ...state, view: action.view, viewKey: action.key, error: undefined } + case 'error': + return { ...state, error: action.error, syncProgress: undefined } + case 'clear-error': + return { ...state, error: undefined } + case 'source': + return { ...state, source: action.source, sel: restoreSelection(state.savedSelections, action.source), query: '', spotifyNavigation: undefined, selectedPlaylist: undefined, selectedTrackIds: new Set(), selectionAnchor: undefined } + case 'playlist': + return { ...state, selectedPlaylist: action.id, spotifyNavigation: undefined, selectedTrackIds: new Set(), selectionAnchor: undefined } + case 'select': { + const sel = selectionAfterFacet(state.sel, action.facet, action.values) + return { ...state, sel, savedSelections: rememberSelection(state.savedSelections, state.source, sel), selectedTrackIds: new Set(), selectionAnchor: undefined } + } + case 'query': + return { ...state, query: action.query, spotifyResults: null, spotifySearching: false, spotifyNavigation: undefined, selectedTrackIds: new Set(), selectionAnchor: undefined } + case 'scope': + return { ...state, scope: action.scope, spotifyNavigation: undefined } + case 'selectTrack': + return { ...state, selectedTrackIds: new Set([action.id]), selectionAnchor: action.id } + case 'selection': + return { ...state, selectedTrackIds: action.ids, selectionAnchor: action.anchor } + case 'play': + return { + ...state, + selectedTrackIds: new Set([action.id]), + selectionAnchor: action.id, + playing: { + trackId: action.id, elapsed: 0, isPlaying: true, queue: action.queue, + uri: action.queue.find((track) => track.id === action.id)?.uri ?? null, + external: false, name: null, art: null, alb: null, durationSecs: null, + shuffle: state.settings.shuffle, origin: action.origin, simulated: true, + }, + } + case 'togglePlay': + return state.playing + ? { ...state, playing: { ...state.playing, isPlaying: !state.playing.isPlaying } } + : state + case 'step': + return state.playing + ? { ...state, playing: { ...state.playing, trackId: action.id, elapsed: 0, isPlaying: true } } + : state + case 'tick': + if (!state.playing?.isPlaying) return state + return state.playing.elapsed + 1 >= action.duration + ? { ...state, playing: { ...state.playing, trackId: action.nextId, elapsed: 0, isPlaying: true } } + : { ...state, playing: { ...state.playing, elapsed: state.playing.elapsed + 1 } } + case 'playerState': + return action.player.trackId === null && !action.player.name + ? { ...state, playing: null } + : { + ...state, + playing: { ...action.player, queue: action.player.external ? emptyTracks : action.queue, origin: action.origin }, + } + case 'seek': + return state.playing + ? { ...state, playing: { ...state.playing, elapsed: action.elapsed } } + : state + case 'hydrateSettings': + return { ...state, settings: action.settings, settingsHydrated: true } + case 'settings': + return { ...state, settings: { ...state.settings, ...action.settings } } + case 'browserPanes': { + const sel = { ...state.sel } + for (const facet of ['cat', 'art', 'alb'] as const) if (!action.browserPanes[facet]) delete sel[facet] + return { ...state, sel, savedSelections: rememberSelection(state.savedSelections, state.source, sel), settings: { ...state.settings, browserPanes: action.browserPanes } } + } + case 'systemTheme': + return { ...state, systemDark: action.dark } + case 'refresh': + return { ...state, revision: state.revision + 1 } + case 'notice': + return { ...state, notice: action.notice } + case 'info': + return { ...state, info: action.info, preferences: false, setup: false } + case 'preferences': + return { ...state, preferences: action.open, setup: false, info: undefined } + case 'setup': + return { ...state, setup: action.open, preferences: false, info: undefined } + case 'playbackAuthorization': + return action.prompt + ? { ...state, playbackAuthorization: action.prompt, info: undefined, preferences: false, setup: false } + : { ...state, playbackAuthorization: null } + case 'connection': + return { ...state, connection: action.connection, playbackAuthorization: action.connection.playback_authorized ? null : state.playbackAuthorization } + case 'lastfm': + return { ...state, lastfm: action.lastfm } + case 'spotifyResults': + return { ...state, spotifyResults: action.results, spotifySearching: false } + case 'spotifySearching': + return { ...state, spotifySearching: action.searching } + case 'spotifyNavigate': + return { ...state, scope: 'spotify', query: '', spotifyNavigation: action.entry, selectedPlaylist: undefined } + case 'syncPhase': + return { ...state, syncPhase: action.phase, syncProgress: action.phase ? state.syncProgress : undefined } + case 'syncProgress': + return { ...state, syncProgress: action.progress.fraction < 1 ? action.progress : undefined } + case 'importStarted': + return { ...state, importStatus: 'Importing local files…' } + case 'importComplete': + return { ...state, importStatus: `Imported ${action.summary.imported} tracks (${action.summary.duplicates} duplicates skipped, ${action.summary.failed.length} failed)` } + case 'importFailed': + return { ...state, importStatus: undefined } + case 'clearImportStatus': + return { ...state, importStatus: undefined } + case 'playlistsRefresh': + return { ...state, playlistRevision: state.playlistRevision + 1 } + } +} diff --git a/apps/desktop/src/diagnostics.ts b/apps/desktop/src/diagnostics.ts new file mode 100644 index 0000000..e2ae74f --- /dev/null +++ b/apps/desktop/src/diagnostics.ts @@ -0,0 +1,27 @@ +export const diagnosticLevels = ['INFO', 'WARN', 'ERROR'] as const +export type DiagnosticLevel = typeof diagnosticLevels[number] + +export type DiagnosticEntry = { + date: string + time: string + level: DiagnosticLevel + target: string + message: string +} + +export type DiagnosticReport = { + entries: DiagnosticEntry[] + emailAvailable: boolean +} + +export const reportWindow = (entries: readonly DiagnosticEntry[]): DiagnosticEntry[] => { + let lastProblem = -1 + entries.forEach((entry, index) => { + if (entry.level === 'WARN' || entry.level === 'ERROR') lastProblem = index + }) + return lastProblem < 0 ? [] : entries.slice(0, lastProblem + 1) +} + +export const formatDiagnosticReport = (entries: readonly DiagnosticEntry[]): string => entries + .map((entry) => `[${entry.date}][${entry.time}][${entry.level}][${entry.target}] ${entry.message}`) + .join('\n') diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx index 2d7370d..1e00a71 100644 --- a/apps/desktop/src/dialogViews.tsx +++ b/apps/desktop/src/dialogViews.tsx @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core' import { useEffect, useMemo, useState } from 'react' -import type { MetadataValues, PlaybackAuthorizationPrompt, PlayThresholdPercent, PlaylistTrack, Settings, Theme, TrackInfo } from './types.ts' +import { diagnosticLevels, formatDiagnosticReport, reportWindow, type DiagnosticLevel, type DiagnosticReport } from './diagnostics.ts' +import type { LastFmState, MetadataValues, PlaybackAuthorizationPrompt, PlayThresholdPercent, PlaylistTrack, Settings, Theme, TrackInfo } from './types.ts' import { clearedTrackRating, overlayEditTargets } from './ui.ts' import { ModalDialog, RatingStars } from './viewShared.tsx' @@ -171,13 +172,76 @@ export function PlaybackAuthorization({ prompt, onCancel, onAuthorize }: { } -export function Preferences({ settings, onZoom, onCancel, onSave }: { +function BugPreferences() { + const [report, setReport] = useState({ entries: [], emailAvailable: false }) + const [levels, setLevels] = useState>(() => new Set(diagnosticLevels)) + const [error, setError] = useState() + const [copied, setCopied] = useState(false) + useEffect(() => { + let active = true + invoke('load_diagnostics') + .then((next) => { if (active) setReport(next) }) + .catch((reason) => { if (active) setError(String(reason)) }) + return () => { active = false } + }, []) + const counts = Object.fromEntries(diagnosticLevels.map((level) => [level, report.entries.filter((entry) => entry.level === level).length])) as Record + const visible = report.entries.filter((entry) => levels.has(entry.level)) + const reportEntries = reportWindow(report.entries) + const body = formatDiagnosticReport(reportEntries) + const copy = async () => { + try { + await navigator.clipboard.writeText(body) + setCopied(true) + window.setTimeout(() => setCopied(false), 1600) + } catch (reason) { + setError(String(reason)) + } + } + const email = async () => { + try { + await invoke('email_diagnostics', { body }) + } catch (reason) { + setError(String(reason)) + } + } + const toggleLevel = (level: DiagnosticLevel) => setLevels((current) => { + const next = new Set(current) + if (next.has(level)) next.delete(level) + else next.add(level) + return next + }) + const problemCount = counts.WARN + counts.ERROR + return <> +
+

Log {report.entries.length} entries this session · {counts.WARN} warnings · {counts.ERROR} errors

+
+
+ {diagnosticLevels.map((level) => )} + +
+
+ {visible.length ? visible.map((entry, index) =>
{entry.date} {entry.time}{entry.level}{entry.target}{entry.message}
) :
No entries at these levels.
} +
+
+
+

Report a problem

+
{problemCount ? `Copies ${reportEntries.length} entries — the session run-up through the last problem.` : 'No warnings or errors this session.'}Filters change the view only. Reports always include the full diagnostic window.
+
+ {error && {error}} + {!report.emailAvailable && Email support is unavailable in this build. Copy Logs and share the report instead.} +
+ +} + +export function Preferences({ settings, lastfm, onZoom, onCancel, onLastfm, onSave }: { settings: Settings + lastfm: LastFmState onZoom: (zoom: number) => void onCancel: () => void - onSave: (settings: Pick) => void + onLastfm: (state: LastFmState) => void + onSave: (settings: Pick) => void }) { - type PreferenceTab = 'appearance' | 'library' | 'audio' + type PreferenceTab = 'appearance' | 'library' | 'audio' | 'bug' const [tab, setTab] = useState('appearance') const [theme, setTheme] = useState(settings.theme) const [browserVisible, setBrowserVisible] = useState(settings.browserVisible) @@ -190,17 +254,36 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: { const [normalizeVolume, setNormalizeVolume] = useState(settings.normalizeVolume) const [gapless, setGapless] = useState(settings.gapless) const [playThresholdPercent, setPlayThresholdPercent] = useState(settings.playThresholdPercent) + const [lastfmScrobbling, setLastfmScrobbling] = useState(settings.lastfmScrobbling) + const [lastfmBusy, setLastfmBusy] = useState(false) + const [lastfmError, setLastfmError] = useState() const tabs: [PreferenceTab, string, string][] = [ ['appearance', '◑', 'Appearance'], ['library', '♫', 'Library'], ['audio', '◉', 'Audio'], + ['bug', '⚠', 'Bug'], ] const themeOptions: [Theme, string, string][] = [ ['system', 'System', 'Follow the OS appearance, switching automatically.'], ['light', 'Light', 'Always use the light theme.'], ['dark', 'Dark', 'Always use the dark theme.'], ] - const save = () => onSave({ theme, browserVisible, browserPanes, autoAddSpotifyLibrary: autoAdd, autoConnect, spotifyClientId: clientId.trim(), playbackBackend, streamingBitrate, normalizeVolume, gapless, playThresholdPercent }) + const save = () => onSave({ theme, browserVisible, browserPanes, autoAddSpotifyLibrary: autoAdd, autoConnect, spotifyClientId: clientId.trim(), playbackBackend, streamingBitrate, normalizeVolume, gapless, playThresholdPercent, lastfmScrobbling }) + const lastfmAction = async (command: 'connect_lastfm' | 'finish_lastfm' | 'disconnect_lastfm') => { + setLastfmBusy(true) + setLastfmError(undefined) + try { + const next = await invoke(command) + onLastfm(next) + if (command === 'finish_lastfm') { + setLastfmScrobbling(true) + } + } catch (error) { + setLastfmError(String(error)) + } finally { + setLastfmBusy(false) + } + } return

Preferences

@@ -211,8 +294,9 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: {

Theme

{themeOptions.map(([value, label, help]) => )}
-

Text size ⌘+ · ⌘− · ⌘0

- {([['Small', .9], ['Medium', 1], ['Large', 1.15]] as const).map(([label, zoom]) => )} +

Text size {Math.round(settings.zoom * 100)}% · ⌘+ · ⌘− · ⌘0

+ onZoom(Number(event.target.value))} /> +
70%180%

Column browser ⌘B

{([['Show', true], ['Hide', false]] as const).map(([label, visible]) => )}
@@ -230,6 +314,28 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: {
+

Last.fm

+
+ {!lastfm.available ?

{lastfm.problem ?? 'Last.fm scrobbling is unavailable in this build.'}

: lastfm.connected ? <> +
+
+ + :
+
} + {lastfm.problem && lastfm.available && {lastfm.problem}} + {lastfmError && lastfmError !== lastfm.problem && {lastfmError}} +
+ Powered by Last.fm. +
} {tab === 'audio' && <>

Streaming quality

@@ -246,6 +352,7 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: { {playThresholds.map((percent) => )}
} + {tab === 'bug' && }
diff --git a/apps/desktop/src/libraryViews.tsx b/apps/desktop/src/libraryViews.tsx index d41fe8a..cec1df8 100644 --- a/apps/desktop/src/libraryViews.tsx +++ b/apps/desktop/src/libraryViews.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { BrowseView, ColumnKey, Playing, PlaylistSubject, Selection, Settings, Source, Track } from './types.ts' -import { COLUMN_SPECS, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, isCurrentTrack, labels, moveBefore, resizedColumnWidth, resizedPaneHeight, trackColumnHeadings, trackGridColumns } from './ui.ts' +import { COLUMN_SPECS, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, isCurrentTrack, labels, moveBefore, resizedColumnWidth, resizedPaneHeight, trackColumnHeadings, trackGridColumns, visibleColumnOrder } from './ui.ts' import { CheckboxMenu, ContextMenu, RatingStars } from './viewShared.tsx' export function BrowserPane({ state, anchors, onActivate, onSelect, onPlay, onToggle }: { @@ -95,9 +95,10 @@ export function AlbumRatingStrip({ album, rating, onRate }: { album: string; rat type TrackCellData = Omit & { id: number | null } -export function TrackCell({ track, column, playing, selected, onInfo, onRate }: { +export function TrackCell({ track, column, facetTitle, playing, selected, onInfo, onRate }: { track: TrackCellData column: ColumnKey + facetTitle: string playing: false | 'playing' | 'paused' selected: boolean onInfo?: () => void @@ -109,7 +110,11 @@ export function TrackCell({ track, column, playing, selected, onInfo, onRate }: if (column === 'time') return {formatTime(track.durationSecs)} if (column === 'artist') return {track.art} if (column === 'album') return {track.alb} - if (column === 'genre') return {track.overridden ? '● ' : ''}{track.cat} + if (column === 'genre') { + const label = facetLabel(facetTitle, track.cat) + const meta = label !== track.cat + return {label} + } if (column === 'plays') return {track.playCount || ''} if (column === 'kind') return {track.kind ?? ''} if (column === 'bitrate') return {track.bitrateKbps === null ? '' : `${track.bitrateKbps} kbps`} @@ -135,7 +140,7 @@ export function TrackList({ tracks, label, selectedIds, playing, columnOrder, co const resize = useRef<{ column: ColumnKey; pointerId: number; startX: number; startWidth: number } | undefined>(undefined) useEffect(() => setLiveWidths(columnWidths), [columnWidths]) const headings = trackColumnHeadings(label) - const visibleColumns = columnOrder.filter((column) => !hiddenColumns.includes(column)) + const visibleColumns = visibleColumnOrder(columnOrder, hiddenColumns) const columns = trackGridColumns(visibleColumns, liveWidths) const moveColumn = (event: React.PointerEvent) => { const active = columnDrag.current @@ -218,7 +223,7 @@ export function TrackList({ tracks, label, selectedIds, playing, columnOrder, co setMenu({ x: event.clientX, y: event.clientY, trackId: track.id }) }}> event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onChange={(event) => onEnabled(track.id, event.target.checked)} /> - {visibleColumns.map((column) => onInfo(track.id)} onRate={(stars) => onRate(track.id, stars)} />)} + {visibleColumns.map((column) => onInfo(track.id)} onRate={(stars) => onRate(track.id, stars)} />)}
})}
diff --git a/apps/desktop/src/spotifySearch.ts b/apps/desktop/src/spotifySearch.ts new file mode 100644 index 0000000..59d476c --- /dev/null +++ b/apps/desktop/src/spotifySearch.ts @@ -0,0 +1,147 @@ +import type { SearchAlbum, SearchArtist, SearchTrack, SpotifyResultGroupKey, SpotifyResults } from './types.ts' + +export type SpotifySearchTab = 'all' | SpotifyResultGroupKey +export type SpotifySearchGroups = { [K in SpotifyResultGroupKey]: SpotifyResults[K] } +export type SpotifySearchState = { + query: string + tab: SpotifySearchTab + pages: ReadonlyMap + groups: SpotifySearchGroups + visible: Record + loading: ReadonlySet + errors: Partial> + generation: number +} +export type SpotifySearchRequest = { group: SpotifyResultGroupKey; offset: number; generation: number } + +export const spotifySearchPendingPageKey = (query: string, offset: number, generation: number) => `${query}:${offset}:${generation}` + +const GROUPS: SpotifyResultGroupKey[] = ['artists', 'albums', 'tracks'] +const groupName = (group: SpotifyResultGroupKey) => group +const title = (group: SpotifyResultGroupKey) => group[0].toUpperCase() + group.slice(1) + +export const spotifySearchVisibleCounts = (tab: SpotifySearchTab): Record => ({ + artists: tab === 'artists' ? 10 : 5, + albums: tab === 'albums' ? 10 : 5, + tracks: tab === 'tracks' ? 10 : 5, +}) + +const itemKey = (group: SpotifyResultGroupKey, item: SearchArtist | SearchAlbum | SearchTrack) => + group === 'artists' ? (item as SearchArtist).id : (item as SearchAlbum | SearchTrack).uri + +function mergeGroupPages(pages: ReadonlyMap, group: K): SpotifyResults[K] { + const items: (SearchArtist | SearchAlbum | SearchTrack)[] = [] + const seen = new Set() + let total = 0 + let nextOffset: number | null = null + for (const [, page] of [...pages.entries()].sort(([left], [right]) => left - right)) { + const current = page[group] + total = current.total + nextOffset = current.nextOffset + for (const item of current.items) { + if (seen.has(itemKey(group, item))) continue + seen.add(itemKey(group, item)) + items.push(item) + } + } + return { items, total, nextOffset } as SpotifyResults[K] +} + +const groupsFromPages = (pages: ReadonlyMap): SpotifySearchGroups => ({ + artists: mergeGroupPages(pages, 'artists'), + albums: mergeGroupPages(pages, 'albums'), + tracks: mergeGroupPages(pages, 'tracks'), +}) + +export const createSpotifySearchState = (query: string): SpotifySearchState => ({ + query, + tab: 'all', + pages: new Map(), + groups: groupsFromPages(new Map()), + visible: spotifySearchVisibleCounts('all'), + loading: new Set(), + errors: {}, + generation: 0, +}) + +export const resetSpotifySearchQuery = (state: SpotifySearchState, query: string) => ({ + ...createSpotifySearchState(query), + generation: state.generation + 1, +}) + +export const replaceSpotifySearchResults = (state: SpotifySearchState, results: SpotifyResults): SpotifySearchState => { + const pages = new Map([[0, results]]) + return { ...state, pages, groups: groupsFromPages(pages), loading: new Set(), errors: {} } +} + +export const setSpotifySearchTab = (state: SpotifySearchState, tab: SpotifySearchTab): SpotifySearchState => + state.tab === tab + ? state + : { ...state, tab, visible: spotifySearchVisibleCounts(tab), loading: new Set(), errors: {}, generation: state.generation + 1 } + +export const expandSpotifySearchGroup = (state: SpotifySearchState, group: SpotifyResultGroupKey): { state: SpotifySearchState; request?: SpotifySearchRequest } => { + if (state.loading.has(group)) return { state } + const visible = state.visible[group] + 10 + const nextState = { ...state, visible: { ...state.visible, [group]: visible }, errors: { ...state.errors } } + delete nextState.errors[group] + const cached = nextState.groups[group] + if (cached.items.length >= visible || cached.nextOffset === null) return { state: nextState } + const loading = new Set(nextState.loading) + loading.add(group) + return { + state: { ...nextState, loading }, + request: { group, offset: cached.nextOffset, generation: state.generation }, + } +} + +export const retrySpotifySearchGroup = (state: SpotifySearchState, group: SpotifyResultGroupKey): { state: SpotifySearchState; request?: SpotifySearchRequest } => { + if (state.loading.has(group) || state.groups[group].nextOffset === null) return { state } + const loading = new Set(state.loading) + loading.add(group) + const errors = { ...state.errors } + delete errors[group] + return { + state: { ...state, visible: { ...state.visible, [group]: state.visible[group] + 10 }, loading, errors }, + request: { group, offset: state.groups[group].nextOffset, generation: state.generation }, + } +} + +export const receiveSpotifySearchPage = (state: SpotifySearchState, group: SpotifyResultGroupKey, offset: number, page: SpotifyResults, generation: number): SpotifySearchState => { + if (generation !== state.generation) return state + const pages = new Map(state.pages) + pages.set(offset, page) + const loading = new Set(state.loading) + loading.delete(group) + const errors = { ...state.errors } + delete errors[group] + return { ...state, pages, groups: groupsFromPages(pages), loading, errors } +} + +export const failSpotifySearchGroup = (state: SpotifySearchState, group: SpotifyResultGroupKey, error: string, generation: number): SpotifySearchState => { + if (generation !== state.generation) return state + const loading = new Set(state.loading) + loading.delete(group) + return { + ...state, + visible: { ...state.visible, [group]: Math.max(0, state.visible[group] - 10) }, + loading, + errors: { ...state.errors, [group]: error }, + } +} + +export const moreSpotifySearchLabel = (state: SpotifySearchState, group: SpotifyResultGroupKey) => { + const remaining = state.groups[group].total - Math.min(state.visible[group], state.groups[group].total) + return remaining > 0 && state.groups[group].nextOffset !== null + ? `View ${Math.min(10, remaining)} more ${groupName(group)}` + : undefined +} + +export const spotifySearchGroupHeader = (state: SpotifySearchState, group: SpotifyResultGroupKey) => { + const current = state.groups[group] + const shown = Math.min(state.visible[group], current.total) + return current.nextOffset === null || shown >= current.total + ? title(group) + : `${title(group)} · ${shown} of ${current.total}` +} + +export const spotifySearchGroupKeys = GROUPS diff --git a/apps/desktop/src/spotifyViews.tsx b/apps/desktop/src/spotifyViews.tsx index d96b4e1..77639da 100644 --- a/apps/desktop/src/spotifyViews.tsx +++ b/apps/desktop/src/spotifyViews.tsx @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core' -import { useEffect, useRef, useState } from 'react' -import type { AlbumPageView, ArtistAlbumsPage, ArtistPageView, PlaybackTrack, PlaylistSubject, SearchAlbum, SpotifyNavEntry, SpotifyResults } from './types.ts' +import { useEffect, useRef, useState, type ReactNode } from 'react' +import type { AlbumPageView, ArtistAlbumsPage, ArtistPageView, PlaybackTrack, PlaylistSubject, SearchAlbum, SearchArtist, SearchTrack, SpotifyNavEntry, SpotifyResults } from './types.ts' +import { createSpotifySearchState, expandSpotifySearchGroup, failSpotifySearchGroup, moreSpotifySearchLabel, receiveSpotifySearchPage, replaceSpotifySearchResults, resetSpotifySearchQuery, retrySpotifySearchGroup, setSpotifySearchTab, spotifySearchGroupHeader, spotifySearchPendingPageKey, type SpotifySearchState } from './spotifySearch.ts' import { DRAG_TYPE, formatTime, mergeByUri, SYNTHETIC_BASE } from './ui.ts' import { ContextMenu, RatingStars } from './viewShared.tsx' @@ -10,7 +11,7 @@ function SpotifyArtwork({ imageUrl, round = false }: { imageUrl: string | null; return {imageUrl ? : } } -function SpotifyAlbumRow({ album, adding, added, onAdd, onRemove, onOpen, onPlaylist, openOnClick = false, showType = false }: { +function SpotifyAlbumRow({ album, adding, added, onAdd, onRemove, onOpen, onPlaylist, openOnClick = false, showType = false, searchActions = false, onPlay, playing = false }: { album: SearchAlbum adding: boolean added: boolean @@ -20,21 +21,83 @@ function SpotifyAlbumRow({ album, adding, added, onAdd, onRemove, onOpen, onPlay onPlaylist: (subject: PlaylistSubject) => void openOnClick?: boolean showType?: boolean + searchActions?: boolean + onPlay?: () => void + playing?: boolean }) { const [menu, setMenu] = useState<{ x: number; y: number }>() const subject: PlaylistSubject = { kind: 'album', label: `Album · ${album.name}`, albumUri: album.uri } - return
{ + return
{ event.dataTransfer.effectAllowed = 'copy' event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(subject)) }} onContextMenu={(event) => { event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY }) }}> {album.name}{showType ? [album.year, album.albumType].filter(Boolean).join(' · ') : <>{album.artist}{album.year && ` · ${album.year}`}} - + {searchActions ? + : } {menu && setMenu(undefined)}>}
} -function SpotifyPageBack({ label, onBack }: { label: string; onBack: () => void }) { +function SpotifyResultActions({ name, meta = '', onPlay, playing = false, adding = false, added = false, onAdd, onRemove, onOpen }: { + name: string + meta?: string + onPlay?: () => void + playing?: boolean + adding?: boolean + added?: boolean + onAdd?: () => void + onRemove?: () => void + onOpen?: () => void +}) { + return + {meta} + {onPlay ? : } + {onAdd && onRemove ? : } + + +} + +function SpotifyArtistRow({ artist, onOpen }: { artist: SearchArtist; onOpen: () => void }) { + return
+ + {artist.name}{artist.descriptor} + +
+} + +function albumPlaybackTracks(page: AlbumPageView): PlaybackTrack[] { + return page.tracks.map((track, index) => ({ + id: track.trackId ?? SYNTHETIC_BASE + index, + uri: track.uri, + name: track.name, + art: page.artist, + alb: page.name, + durationSecs: track.durationSecs, + enabled: track.enabled, + })) +} + +function searchTrackPlayback(track: SearchTrack, index: number): PlaybackTrack { + return { id: SYNTHETIC_BASE + index, uri: track.uri, name: track.name, art: track.artist, alb: track.alb, durationSecs: track.durationSecs, enabled: true } +} + +function SpotifySearchSection({ group, state, onMore, onRetry, children }: { group: keyof SpotifyResults; state: SpotifySearchState; onMore: () => void; onRetry: () => void; children: ReactNode }) { + const resultGroup = state.groups[group] + const loading = state.loading.has(group) + const error = state.errors[group] + const more = moreSpotifySearchLabel(state, group) + return
+

{spotifySearchGroupHeader(state, group)}

+ {children} + {!resultGroup.items.length && !loading && !error &&

No {group} found.

} + {loading &&

Loading {group}…

} + {error &&
{error}
} + {!loading && !error && more &&
} +
+} + +export function SpotifyPageBack({ label, onBack }: { label: string; onBack: () => void }) { return } @@ -74,15 +137,7 @@ function SpotifyAlbumPage({ entry, backLabel, adding, playingUri, onBack, onArti if (page && entry.highlight) highlighted.current?.scrollIntoView({ block: 'center' }) }, [entry.highlight, page]) if (!page) return
Loading album…
- const tracks: PlaybackTrack[] = page.tracks.map((track, index) => ({ - id: track.trackId ?? SYNTHETIC_BASE + index, - uri: track.uri, - name: track.name, - art: page.artist, - alb: page.name, - durationSecs: track.durationSecs, - enabled: track.enabled, - })) + const tracks = albumPlaybackTracks(page) const refresh = () => setRevision((current) => current + 1) const trackIsInLibrary = (track: AlbumPageView['tracks'][number]) => trackMembership[track.uri] ?? track.trackId !== null const albumInLibrary = page.tracks.length > 0 && page.tracks.every(trackIsInLibrary) @@ -285,57 +340,148 @@ function SpotifyArtistPage({ id, backLabel, adding, added, removed, onBack, onAl
} -export function SpotifySearch({ query, searching, results, navigation, playingUri, onAdd, onPlay, onPlaylist, onClose, onError }: { +export function SpotifySearch({ query, searching, results, navigation, playingUri, onAdd, onAddTrack, onRemoveTrack, onPlay, onPlaylist, onClose, onError }: { query: string searching: boolean results: SpotifyResults | null navigation?: SpotifyNavEntry playingUri: string | null onAdd: (album: { uri: string; name: string; artist: string }) => Promise + onAddTrack: (uri: string) => Promise + onRemoveTrack: (uri: string) => Promise onPlay: (id: number, tracks: readonly PlaybackTrack[]) => void onPlaylist: (subject: PlaylistSubject) => void onClose: () => void onError: (error: string) => void }) { - const [tab, setTab] = useState('all') + const [searchState, setSearchState] = useState(() => createSpotifySearchState(query)) + const searchStateRef = useRef(searchState) const [adding, setAdding] = useState() + const [playingAlbum, setPlayingAlbum] = useState() const [added, setAdded] = useState>(new Set()) const [removed, setRemoved] = useState>(new Set()) const [nav, setNav] = useState(navigation ? [navigation] : []) - const [menu, setMenu] = useState<{ x: number; y: number; track: SpotifyResults['tracks'][number] }>() + const [menu, setMenu] = useState<{ x: number; y: number; track: SpotifyResults['tracks']['items'][number] }>() + const pendingPages = useRef(new Map>()) + searchStateRef.current = searchState useEffect(() => { - setTab('all') + setSearchState((current) => { + const next = current.query === query ? current : resetSpotifySearchQuery(current, query) + searchStateRef.current = next + return next + }) setAdded(new Set()) setRemoved(new Set()) setNav(navigation ? [navigation] : []) }, [query, navigation]) + useEffect(() => { + if (!results) return + setSearchState((current) => replaceSpotifySearchResults(current, results)) + }, [results]) + const requestGroup = (group: keyof SpotifyResults, retry = false) => { + const current = searchStateRef.current + const outcome = retry ? retrySpotifySearchGroup(current, group) : expandSpotifySearchGroup(current, group) + searchStateRef.current = outcome.state + setSearchState(outcome.state) + if (!outcome.request) return + const request = outcome.request + const key = spotifySearchPendingPageKey(query, request.offset, request.generation) + let page = pendingPages.current.get(key) + if (!page) { + page = invoke('spotify_search', { query, offset: request.offset }) + .then((next) => { + pendingPages.current.delete(key) + return next + }, (error) => { + pendingPages.current.delete(key) + throw error + }) + pendingPages.current.set(key, page) + } + page.then((next) => setSearchState((state) => receiveSpotifySearchPage(state, request.group, request.offset, next, request.generation))) + .catch((error) => setSearchState((state) => failSpotifySearchGroup(state, request.group, String(error), request.generation))) + } const add = async (album: { uri: string; name: string; artist: string }) => { + const wasAdded = added.has(album.uri) + const wasRemoved = removed.has(album.uri) + setAdded((previous) => new Set(previous).add(album.uri)) + setRemoved((previous) => { const next = new Set(previous); next.delete(album.uri); return next }) setAdding(album.uri) try { await onAdd(album) - setAdded((previous) => new Set(previous).add(album.uri)) - setRemoved((previous) => { const next = new Set(previous); next.delete(album.uri); return next }) return true } catch { + setAdded((previous) => { const next = new Set(previous); if (wasAdded) next.add(album.uri); else next.delete(album.uri); return next }) + setRemoved((previous) => { const next = new Set(previous); if (wasRemoved) next.add(album.uri); else next.delete(album.uri); return next }) return false } finally { setAdding(undefined) } } const remove = async (uri: string) => { + const wasAdded = added.has(uri) + const wasRemoved = removed.has(uri) + setRemoved((previous) => new Set(previous).add(uri)) + setAdded((previous) => { const next = new Set(previous); next.delete(uri); return next }) setAdding(uri) try { await invoke('remove_spotify_album', { uri }) - setRemoved((previous) => new Set(previous).add(uri)) - setAdded((previous) => { const next = new Set(previous); next.delete(uri); return next }) return true } catch (error) { + setAdded((previous) => { const next = new Set(previous); if (wasAdded) next.add(uri); else next.delete(uri); return next }) + setRemoved((previous) => { const next = new Set(previous); if (wasRemoved) next.add(uri); else next.delete(uri); return next }) onError(String(error)) return false } finally { setAdding(undefined) } } + const addTrack = async (track: SearchTrack) => { + const wasAdded = added.has(track.uri) + const wasRemoved = removed.has(track.uri) + setAdded((previous) => new Set(previous).add(track.uri)) + setRemoved((previous) => { const next = new Set(previous); next.delete(track.uri); return next }) + setAdding(track.uri) + try { + await onAddTrack(track.uri) + return true + } catch { + setAdded((previous) => { const next = new Set(previous); if (wasAdded) next.add(track.uri); else next.delete(track.uri); return next }) + setRemoved((previous) => { const next = new Set(previous); if (wasRemoved) next.add(track.uri); else next.delete(track.uri); return next }) + return false + } finally { + setAdding(undefined) + } + } + const removeTrack = async (uri: string) => { + const wasAdded = added.has(uri) + const wasRemoved = removed.has(uri) + setRemoved((previous) => new Set(previous).add(uri)) + setAdded((previous) => { const next = new Set(previous); next.delete(uri); return next }) + setAdding(uri) + try { + await onRemoveTrack(uri) + return true + } catch { + setAdded((previous) => { const next = new Set(previous); if (wasAdded) next.add(uri); else next.delete(uri); return next }) + setRemoved((previous) => { const next = new Set(previous); if (wasRemoved) next.add(uri); else next.delete(uri); return next }) + return false + } finally { + setAdding(undefined) + } + } + const playAlbum = async (album: SearchAlbum) => { + setPlayingAlbum(album.uri) + try { + const page = await invoke('spotify_album_page', { uri: album.uri }) + const tracks = albumPlaybackTracks(page) + if (tracks.length) onPlay(tracks[0].id, tracks) + } catch (error) { + onError(String(error)) + } finally { + setPlayingAlbum(undefined) + } + } const pushAlbum = (uri: string, highlight?: string) => setNav((current) => [...current, { kind: 'album', uri, highlight }]) const top = nav[nav.length - 1] const below = nav[nav.length - 2] @@ -344,10 +490,11 @@ export function SpotifySearch({ query, searching, results, navigation, playingUr if (searching) return
Searching Spotify…
if (top?.kind === 'album') return setNav((current) => [...current, { kind: 'artist', id }])} onAdd={add} onRemove={remove} onPlay={onPlay} onPlaylist={onPlaylist} onError={onError} /> if (top?.kind === 'artist') return + const tab = searchState.tab const counts = { - artists: results?.artists.length ?? 0, - albums: results?.albums.length ?? 0, - tracks: results?.tracks.length ?? 0, + artists: searchState.groups.artists.total, + albums: searchState.groups.albums.total, + tracks: searchState.groups.tracks.total, } const tabs: { key: SpotifyTab; label: string; count: number }[] = [ { key: 'all', label: 'All', count: counts.artists + counts.albums + counts.tracks }, @@ -357,33 +504,27 @@ export function SpotifySearch({ query, searching, results, navigation, playingUr ] return
- {tabs.map((item) => )} + {tabs.map((item) => )} Spotify · "{query}"
- {(tab === 'all' || tab === 'artists') &&
- {tab === 'all' &&

Artists

} - {results?.artists.map((artist) =>
- - {artist.name}{artist.descriptor} - -
)} - {!results?.artists.length &&

No artists found.

} -
} - {(tab === 'all' || tab === 'albums') &&
- {tab === 'all' &&

Albums

} - {results?.albums.map((album) => { void add(album) }} onRemove={() => { void remove(album.uri) }} onOpen={() => pushAlbum(album.uri)} onPlaylist={onPlaylist} />)} - {!results?.albums.length &&

No albums found.

} -
} - {(tab === 'all' || tab === 'tracks') &&
- {tab === 'all' &&

Tracks

} - {results?.tracks.map((track) =>
{ event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, track }) }}> - - {track.name}{track.artist} · {track.alb} - -
)} - {!results?.tracks.length &&

No tracks found.

} -
} + {(tab === 'all' || tab === 'artists') && requestGroup('artists')} onRetry={() => requestGroup('artists', true)}> + {searchState.groups.artists.items.slice(0, searchState.visible.artists).map((artist) => setNav((current) => [...current, { kind: 'artist', id: artist.id }])} />)} + } + {(tab === 'all' || tab === 'albums') && requestGroup('albums')} onRetry={() => requestGroup('albums', true)}> + {searchState.groups.albums.items.slice(0, searchState.visible.albums).map((album) => { void add(album) }} onRemove={() => { void remove(album.uri) }} onOpen={() => pushAlbum(album.uri)} onPlaylist={onPlaylist} searchActions onPlay={() => { void playAlbum(album) }} playing={playingAlbum === album.uri || playingUri === album.uri} />)} + } + {(tab === 'all' || tab === 'tracks') && requestGroup('tracks')} onRetry={() => requestGroup('tracks', true)}> + {searchState.groups.tracks.items.slice(0, searchState.visible.tracks).map((track, index) => { + const open = () => { if (track.albumUri) pushAlbum(track.albumUri, track.uri) } + const playback = searchTrackPlayback(track, index) + return
{ event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, track }) }}> + + {track.name}{track.artist} · {track.alb} + onPlay(playback.id, [playback])} playing={playingUri === track.uri} adding={adding === track.uri} added={(track.inLibrary || added.has(track.uri)) && !removed.has(track.uri)} onAdd={() => { void addTrack(track) }} onRemove={() => { void removeTrack(track.uri) }} onOpen={track.albumUri ? open : undefined} /> +
+ })} +
}
{menu && setMenu(undefined)}>