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 6e413c7..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 @@ -2038,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, @@ -2081,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, @@ -2090,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( @@ -2100,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() { @@ -2143,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() { @@ -2188,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, @@ -2202,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 @@ -3269,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(), @@ -3278,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, @@ -3295,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 @@ -3326,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); @@ -3367,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(); @@ -3384,8 +3478,8 @@ mod tests { "album", "genre", "rating", - "disc", "plays", + "disc", "kind", "bitrate", "lastPlayed", @@ -3396,8 +3490,8 @@ mod tests { assert_eq!( settings.hidden_columns, [ - "disc", "genre", + "disc", "kind", "bitrate", "lastPlayed", @@ -3407,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/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 412ed64..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; } @@ -276,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; } @@ -322,7 +326,7 @@ button { color: inherit; } .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; } @@ -331,7 +335,7 @@ button { color: inherit; } .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); } @@ -349,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 b3f1426..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 { appliedZoom, 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, staleSelectionFacet, 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." @@ -20,241 +21,6 @@ const ZOOM_BASE = 1.15 // 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, 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 '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) @@ -326,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 @@ -392,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() { @@ -449,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] @@ -522,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( @@ -544,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 })) @@ -766,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 @@ -836,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)} @@ -850,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 })} @@ -943,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 @@ -1252,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 }) { @@ -1275,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([]) @@ -1313,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) @@ -1400,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. :-(
: <>
{ @@ -1408,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 a9b9232..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

@@ -231,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

@@ -247,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/spotifyViews.tsx b/apps/desktop/src/spotifyViews.tsx index 2a37178..77639da 100644 --- a/apps/desktop/src/spotifyViews.tsx +++ b/apps/desktop/src/spotifyViews.tsx @@ -97,7 +97,7 @@ function SpotifySearchSection({ group, state, onMore, onRetry, children }: { gro } -function SpotifyPageBack({ label, onBack }: { label: string; onBack: () => void }) { +export function SpotifyPageBack({ label, onBack }: { label: string; onBack: () => void }) { return } diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index be44f43..b9156c0 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -19,6 +19,8 @@ export type Settings = { columnWidths: Partial> hiddenColumns: ColumnKey[] playlistHiddenColumns: Record + playlistColumnOrders: Record + playlistColumnWidths: Record>> sortColumn: ColumnKey | null sortDesc: boolean autoAddSpotifyLibrary: boolean @@ -33,9 +35,18 @@ export type Settings = { normalizeVolume: boolean gapless: boolean playThresholdPercent: PlayThresholdPercent + lastfmScrobbling: boolean } export type ConnectionState = { connected: boolean; needs_reauth: boolean; playback_authorized: boolean } +export type LastFmState = { + available: boolean + connected: boolean + username: string | null + pending: boolean + reconnectRequired: boolean + problem: string | null +} export type PlaybackAuthorizationPrompt = { reason: 'missing' | 'rejected' message: string diff --git a/apps/desktop/src/ui.ts b/apps/desktop/src/ui.ts index 82280ef..0e54d04 100644 --- a/apps/desktop/src/ui.ts +++ b/apps/desktop/src/ui.ts @@ -1,7 +1,13 @@ -import type { BrowseView, ColumnKey, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayOutcome, Playing, PlaylistSubject, Selection, Source, Track } from './types.ts' +import type { BrowseView, ColumnKey, PlaybackAuthorizationPrompt, PlaybackOrigin, PlaybackTrack, PlayOutcome, PlaylistSubject, Selection, Settings, Source, Track } from './types.ts' export type NativeDragEvent = { type: 'enter'; paths: string[] } | { type: 'over' } | { type: 'drop' } | { type: 'leave' } +export const LIBRARY_DEFAULT_COLUMN_ORDER: ColumnKey[] = ['track', 'name', 'artist', 'album', 'time', 'plays', 'rating', 'genre', 'disc', 'kind', 'bitrate', 'lastPlayed', 'added', 'releaseDate'] +export const LIBRARY_DEFAULT_HIDDEN_COLUMNS: ColumnKey[] = ['disc', 'kind', 'bitrate', 'lastPlayed', 'added', 'releaseDate'] +export const PLAYLIST_DEFAULT_COLUMN_ORDER: ColumnKey[] = ['name', 'artist', 'album', 'time', 'rating', 'plays', 'genre', 'disc', 'kind', 'bitrate', 'lastPlayed', 'added', 'releaseDate', 'track'] +export const PLAYLIST_DEFAULT_HIDDEN_COLUMNS: ColumnKey[] = ['disc', 'kind', 'bitrate', 'lastPlayed', 'added', 'releaseDate', 'track'] +export const PLAYLIST_COLUMNS: readonly ColumnKey[] = PLAYLIST_DEFAULT_COLUMN_ORDER + export const nextNativeDragActive = (active: boolean, event: NativeDragEvent) => { if (event.type === 'enter') return event.paths.length > 0 return event.type === 'over' ? active : false @@ -23,6 +29,23 @@ export const selectionAfterFacet = (selection: Selection, facet: keyof Selection : facet === 'art' ? { cat: selection.cat, art: values } : { ...selection, alb: values } +export const rememberSelection = (selections: Record, source: Source, selection: Selection) => ({ ...selections, [source]: selection }) +export const restoreSelection = (selections: Record, source: Source) => selections[source] ?? {} + +export const visibleColumnOrder = (order: ColumnKey[], hidden: ColumnKey[]) => order.filter((column) => !hidden.includes(column)) +export const playlistOverride = (overrides: Record, id: string, value: T, defaultValue: T) => { + const next = { ...overrides } + if (JSON.stringify(value) === JSON.stringify(defaultValue)) delete next[id] + else next[id] = value + return next +} + +export const playlistLayoutFor = (id: string | undefined, settings: Pick) => ({ + hiddenColumns: id !== undefined ? settings.playlistHiddenColumns[id] ?? PLAYLIST_DEFAULT_HIDDEN_COLUMNS : PLAYLIST_DEFAULT_HIDDEN_COLUMNS, + columnOrder: id !== undefined ? settings.playlistColumnOrders[id] ?? PLAYLIST_DEFAULT_COLUMN_ORDER : PLAYLIST_DEFAULT_COLUMN_ORDER, + columnWidths: id !== undefined ? settings.playlistColumnWidths[id] ?? {} : {}, +}) + export const staleSelectionFacet = (selection: Selection, facets: BrowseView['facets']): 'cat' | 'art' | null => { const missing = (selected: string[] | undefined, available: string[]) => selected?.some((value) => !available.includes(value)) ?? false if (missing(selection.cat, facets.cats)) return 'cat' @@ -54,15 +77,6 @@ export const pendingPlaybackTarget = (prompt: PlaybackAuthorizationPrompt, track export const playbackRetryReady = (connected: boolean, playbackAuthorized: boolean, awaitingAuthorization: boolean) => connected && (!awaitingAuthorization || playbackAuthorized) -export const replacementQueue = (tracks: readonly PlaybackTrack[], playing: Playing | null) => { - if (!playing || playing.external || playing.trackId === null) return null - const queue = playbackQueue(tracks, playing.trackId) - const index = queue.findIndex((track) => isCurrentTrack(playing, track)) - const unchanged = queue.length === playing.queue.length - && queue.every((track, row) => playing.queue[row]?.id === track.id && playing.queue[row]?.uri === track.uri) - return index < 0 || unchanged ? null : { queue, index } -} - export const playbackOriginAction = (origin: PlaybackOrigin) => origin.kind === 'playlist' ? { type: 'playlist' as const, id: origin.id } : { type: 'source' as const, source: origin.source } diff --git a/apps/desktop/test/ui.test.ts b/apps/desktop/test/ui.test.ts index 83160ae..03efbd2 100644 --- a/apps/desktop/test/ui.test.ts +++ b/apps/desktop/test/ui.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict' import test from 'node:test' -import type { PlaybackTrack, Playing, Selection, SpotifyResults } from '../src/types.ts' +import { formatDiagnosticReport, reportWindow, type DiagnosticEntry } from '../src/diagnostics.ts' +import { initialState, reducer, type Action } from '../src/appState.ts' +import type { BrowseView, PlaybackTrack, Selection, Settings, SpotifyResults } from '../src/types.ts' import { createSpotifySearchState, expandSpotifySearchGroup, failSpotifySearchGroup, moreSpotifySearchLabel, receiveSpotifySearchPage, replaceSpotifySearchResults, resetSpotifySearchQuery, retrySpotifySearchGroup, setSpotifySearchTab, spotifySearchGroupHeader, spotifySearchPendingPageKey } from '../src/spotifySearch.ts' -import { appliedZoom, browseRequestKey, browseViewForRequest, clearedTrackRating, compareTracks, contiguousRange, dialogTabTarget, facetLabel, insertionIndexAtY, isCurrentTrack, menuPosition, mergeByUri, moveBefore, moveToIndex, nextNativeDragActive, normalizeZoom, overlayEditTargets, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistRows, replacementQueue, resizedColumnWidth, resizedPaneHeight, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE } from '../src/ui.ts' +import { appliedZoom, browseRequestKey, browseViewForRequest, clearedTrackRating, compareTracks, contiguousRange, dialogTabTarget, facetLabel, insertionIndexAtY, isCurrentTrack, LIBRARY_DEFAULT_COLUMN_ORDER, LIBRARY_DEFAULT_HIDDEN_COLUMNS, menuPosition, mergeByUri, moveBefore, moveToIndex, nextNativeDragActive, normalizeZoom, overlayEditTargets, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, rememberSelection, restoreSelection, resizedColumnWidth, resizedPaneHeight, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE, visibleColumnOrder } from '../src/ui.ts' const searchPage = (overrides: Partial = {}): SpotifyResults => ({ artists: { items: Array.from({ length: 10 }, (_, index) => ({ id: `artist-${index}`, name: `Artist ${index}`, descriptor: '', imageUrl: null })), total: 21, nextOffset: 10 }, @@ -11,6 +13,15 @@ const searchPage = (overrides: Partial = {}): SpotifyResults => ...overrides, }) +test('diagnostic reports include session context through the last problem only', () => { + const entry = (level: DiagnosticEntry['level'], message: string): DiagnosticEntry => ({ date: '2026-08-16', time: '12:00:00', level, target: 'retune', message }) + const entries = [entry('INFO', 'start'), entry('WARN', 'retry'), entry('INFO', 'context'), entry('ERROR', 'failed'), entry('INFO', 'trailing')] + const report = reportWindow(entries) + assert.deepEqual(report.map(({ message }) => message), ['start', 'retry', 'context', 'failed']) + assert.match(formatDiagnosticReport(report), /^\[2026-08-16\]\[12:00:00\]\[INFO\]\[retune\] start/) + assert.deepEqual(reportWindow([entry('INFO', 'healthy')]), []) +}) + test('Spotify search uses 5 rows in All and 10 in filtered tabs', () => { const all = createSpotifySearchState('jazz') assert.deepEqual(all.visible, { artists: 5, albums: 5, tracks: 5 }) @@ -127,6 +138,146 @@ test('facet selection preserves broader columns and clears narrower columns', () assert.deepEqual(selectionAfterFacet(selection, 'alb', ['The Hobbit']), { ...selection, alb: ['The Hobbit'] }) }) +test('facet selections are remembered independently for each library source', () => { + let saved = { music: {}, podcasts: {}, audiobooks: {} } + saved = rememberSelection(saved, 'music', { cat: ['Rock'], art: ['Artist'] }) + saved = rememberSelection(saved, 'podcasts', { cat: ['News'] }) + + assert.deepEqual(restoreSelection(saved, 'music'), { cat: ['Rock'], art: ['Artist'] }) + assert.deepEqual(restoreSelection(saved, 'podcasts'), { cat: ['News'] }) + assert.deepEqual(restoreSelection(saved, 'audiobooks'), {}) +}) + +test('navigation transitions preserve the active playback queue and origin', () => { + const queue: PlaybackTrack[] = [ + { id: 1, uri: 'fixture:track:1', name: 'One', art: 'Artist', alb: 'Album', durationSecs: 180, enabled: true }, + { id: 2, uri: 'fixture:track:2', name: 'Two', art: 'Artist', alb: 'Album', durationSecs: 180, enabled: true }, + ] + const origin = { kind: 'playlist', id: 'playlist-a' } as const + const view: BrowseView = { + facets: { cats: [], arts: [], albs: [] }, + tracks: [], + albumRating: null, + albumRatingArtist: null, + albumRatingAmbiguous: false, + counts: { tracks: 0, totalSecs: 0, perSource: { music: 0, podcasts: 0, audiobooks: 0 } }, + } + const transitions: Action[] = [ + { type: 'view', view, key: 'library-view' }, + { type: 'source', source: 'podcasts' }, + { type: 'select', facet: 'cat', values: ['The Hobbit'] }, + { type: 'playlist', id: 'playlist-a' }, + { type: 'spotifyNavigate', entry: { kind: 'artist', id: 'artist-a' } }, + { type: 'playlist' }, + ] + let state = reducer(initialState, { type: 'play', id: 2, queue, origin }) + + for (const transition of transitions) { + state = reducer(state, transition) + assert.deepEqual(state.playing?.queue, queue) + assert.deepEqual(state.playing?.origin, origin) + } +}) + +test('source, pane, and playlist transitions restore and retain the intended selection', () => { + let state = reducer(initialState, { type: 'select', facet: 'cat', values: ['Rock'] }) + state = reducer(state, { type: 'select', facet: 'art', values: ['Artist'] }) + state = reducer(state, { type: 'select', facet: 'alb', values: ['Album'] }) + state = reducer(state, { type: 'source', source: 'podcasts' }) + state = reducer(state, { type: 'select', facet: 'cat', values: ['The Hobbit'] }) + state = reducer(state, { type: 'source', source: 'music' }) + + assert.deepEqual(state.sel, { cat: ['Rock'], art: ['Artist'], alb: ['Album'] }) + state = reducer(state, { type: 'browserPanes', browserPanes: { cat: false, art: true, alb: true } }) + assert.deepEqual(state.sel, { art: ['Artist'], alb: ['Album'] }) + assert.deepEqual(state.savedSelections.music, state.sel) + + state = reducer(state, { type: 'playlist', id: 'playlist-a' }) + state = reducer(state, { type: 'playlist' }) + assert.equal(state.selectedPlaylist, undefined) + assert.deepEqual(state.sel, { art: ['Artist'], alb: ['Album'] }) +}) + +test('library and playlist defaults expose the approved visible column orders', () => { + assert.deepEqual(visibleColumnOrder(LIBRARY_DEFAULT_COLUMN_ORDER, LIBRARY_DEFAULT_HIDDEN_COLUMNS), ['track', 'name', 'artist', 'album', 'time', 'plays', 'rating', 'genre']) + assert.deepEqual(visibleColumnOrder(PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS), ['name', 'artist', 'album', 'time', 'rating', 'plays', 'genre']) + assert.equal(PLAYLIST_DEFAULT_COLUMN_ORDER.at(-1), 'track') + assert.equal(PLAYLIST_DEFAULT_HIDDEN_COLUMNS.at(-1), 'track') +}) + +test('playlist layout resolution uses its keyed override instead of Library defaults', () => { + const customOrder = [...PLAYLIST_DEFAULT_COLUMN_ORDER].reverse() + const settings: Pick = { + playlistHiddenColumns: { 'playlist-a': ['genre'] }, + playlistColumnOrders: { 'playlist-a': customOrder }, + playlistColumnWidths: { 'playlist-a': { name: 220 } }, + } + + assert.deepEqual(playlistLayoutFor('playlist-a', settings), { + hiddenColumns: ['genre'], + columnOrder: customOrder, + columnWidths: { name: 220 }, + }) + assert.deepEqual(playlistLayoutFor('playlist-b', settings), { + hiddenColumns: PLAYLIST_DEFAULT_HIDDEN_COLUMNS, + columnOrder: PLAYLIST_DEFAULT_COLUMN_ORDER, + columnWidths: {}, + }) +}) + +test('playlist layout overrides stay keyed and disappear when restored to defaults', () => { + const customOrder = [...PLAYLIST_DEFAULT_COLUMN_ORDER].reverse() + const orders = playlistOverride({}, 'playlist-a', customOrder, PLAYLIST_DEFAULT_COLUMN_ORDER) + assert.deepEqual(orders['playlist-a'], customOrder) + const otherOrder = [...PLAYLIST_DEFAULT_COLUMN_ORDER].slice(1).concat(PLAYLIST_DEFAULT_COLUMN_ORDER[0]) + const bothOrders = playlistOverride(orders, 'playlist-b', otherOrder, PLAYLIST_DEFAULT_COLUMN_ORDER) + assert.deepEqual(bothOrders, { 'playlist-a': customOrder, 'playlist-b': otherOrder }) + const restoredOrders = playlistOverride(bothOrders, 'playlist-a', PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_COLUMN_ORDER) + assert.deepEqual(restoredOrders, { 'playlist-b': otherOrder }) + + const hidden = playlistOverride({}, 'playlist-a', ['genre'], PLAYLIST_DEFAULT_HIDDEN_COLUMNS) + const bothHidden = playlistOverride(hidden, 'playlist-b', ['plays'], PLAYLIST_DEFAULT_HIDDEN_COLUMNS) + assert.deepEqual(bothHidden, { 'playlist-a': ['genre'], 'playlist-b': ['plays'] }) + assert.deepEqual(playlistOverride(bothHidden, 'playlist-a', PLAYLIST_DEFAULT_HIDDEN_COLUMNS, PLAYLIST_DEFAULT_HIDDEN_COLUMNS), { 'playlist-b': ['plays'] }) + + const widths = playlistOverride({}, 'playlist-a', { name: 220 }, {}) + const bothWidths = playlistOverride(widths, 'playlist-b', { artist: 180 }, {}) + assert.deepEqual(bothWidths, { 'playlist-a': { name: 220 }, 'playlist-b': { artist: 180 } }) + assert.deepEqual(playlistOverride(bothWidths, 'playlist-a', {}, {}), { 'playlist-b': { artist: 180 } }) + + const customized: Pick = { + playlistHiddenColumns: bothHidden, + playlistColumnOrders: bothOrders, + playlistColumnWidths: bothWidths, + } + assert.deepEqual(playlistLayoutFor('playlist-a', customized), { + hiddenColumns: ['genre'], + columnOrder: customOrder, + columnWidths: { name: 220 }, + }) + assert.deepEqual(playlistLayoutFor('playlist-b', customized), { + hiddenColumns: ['plays'], + columnOrder: otherOrder, + columnWidths: { artist: 180 }, + }) + + const restored: Pick = { + playlistHiddenColumns: playlistOverride(bothHidden, 'playlist-a', PLAYLIST_DEFAULT_HIDDEN_COLUMNS, PLAYLIST_DEFAULT_HIDDEN_COLUMNS), + playlistColumnOrders: restoredOrders, + playlistColumnWidths: playlistOverride(bothWidths, 'playlist-a', {}, {}), + } + assert.deepEqual(playlistLayoutFor('playlist-a', restored), { + hiddenColumns: PLAYLIST_DEFAULT_HIDDEN_COLUMNS, + columnOrder: PLAYLIST_DEFAULT_COLUMN_ORDER, + columnWidths: {}, + }) + assert.deepEqual(playlistLayoutFor('playlist-b', restored), { + hiddenColumns: ['plays'], + columnOrder: otherOrder, + columnWidths: { artist: 180 }, + }) +}) + test('stale browse selections fall back at the narrowest invalid level', () => { const facets = { cats: ['Rock', 'Jazz'], arts: ['Artist', 'Other'], albs: ['Album', 'Other Album'] } const cases: { selection: Selection; expected: 'cat' | 'art' | null }[] = [ @@ -141,19 +292,6 @@ test('stale browse selections fall back at the narrowest invalid level', () => { for (const { selection, expected } of cases) assert.equal(staleSelectionFacet(selection, facets), expected) }) -test('a resolved view containing the current track replaces its queue at that row', () => { - const tracks = [ - { id: 1, uri: 'spotify:track:1', enabled: true }, - { id: 2, uri: 'spotify:track:2', enabled: true }, - { id: 3, uri: 'spotify:track:3', enabled: true }, - ] as PlaybackTrack[] - const playing = { trackId: 2, uri: tracks[1].uri, external: false, queue: [tracks[1]] } as Playing - - assert.deepEqual(replacementQueue(tracks, playing), { queue: tracks, index: 1 }) - assert.equal(replacementQueue(tracks, { ...playing, uri: 'spotify:track:else' }), null) - assert.equal(replacementQueue(tracks, { ...playing, queue: tracks }), null) -}) - test('the music catch-all has a user-facing genre label', () => { assert.equal(facetLabel('Genre', 'Uncategorized'), 'No Genre') assert.equal(facetLabel('Category', 'Uncategorized'), 'Uncategorized') diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2d64b39..fe50a7d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -16,7 +16,10 @@ Configure the Spotify application's redirect URI to the loopback address shown by Retune. Debug builds and `scripts/build-install.sh` use the app's development token file; release builds encrypt tokens and use the platform-native credential -store for the encryption key. +store for the encryption key. Last.fm is optional: local builds can read +`RETUNE_LASTFM_API_KEY` and `RETUNE_LASTFM_SHARED_SECRET` from the ignored, +owner-only repo-root `.env.lastfm.local` file. Do not put real credentials in +tracked files or frontend/Vite environment variables. ## Run @@ -30,6 +33,22 @@ Package a production-like release with `npm exec tauri build` from `apps/desktop`. On macOS, for local release-mode testing without repeated native credential prompts, run `scripts/build-install.sh` from the repository root. +For direct local Tauri development, create `.env.lastfm.local` with those two +`RETUNE_*` assignments, run `chmod 600 .env.lastfm.local`, then source it before +starting Tauri: + +```sh +set -a +. ./.env.lastfm.local +set +a +cd apps/desktop +npm exec tauri dev +``` + +If the file is absent or either value is empty, Retune remains usable and shows +Last.fm as unavailable. Release builds receive the credentials only on the +trusted native bundle step and fail there if either value is missing. + Native CI builds the Tauri app bundle on macOS arm64, Windows x64/ARM64, and Ubuntu 22.04 amd64/arm64. The Windows and Linux jobs run release Rust tests, including local-file import/playback tests, before building their native bundle; diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 8b7ffd8..0ff1947 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -14,6 +14,20 @@ All JSON state writes use a temporary file followed by atomic rename. | `artist-genres.json` | Persistent Spotify artist-genre cache | | `tokens.enc` | Encrypted release OAuth token state | | `dev-tokens.json` | Development token state; mode 0600 on Unix | +| `dev-lastfm-session.json` | Development Last.fm session; mode 0600 on Unix | +| `lastfm-pending-token.json` | Short-lived Last.fm authorization token; mode 0600 on Unix | +| `lastfm-scrobbles.json` | Ordered durable Last.fm scrobble queue; excluded from backup | + +The official Tauri window-state plugin manages the main native window's size, +position, and maximized state in machine-local application state. Its lifecycle +handles restoring and saving this state; it is not part of backup/export. + +The Preferences Bug tab reads the current rotating application log directly. +A startup marker limits the viewer to the current process session. View filters +do not change the report window: Copy Logs and Email include session entries +through the final warning or error and omit trailing informational entries. +Email support is compiled from optional `RETUNE_SUPPORT_EMAIL`; missing local +configuration disables only Email, and the frontend never receives the address. The token record has an optional reusable built-in playback credential containing the librespot username and AP authentication bytes. Its absence is the default, @@ -31,8 +45,13 @@ The playlist cache retains Spotify display metadata for every fetched track, including disc/track numbers and album release date. Older caches deserialize with defaults and are refreshed once before snapshot-based fetch skipping resumes. -Column visibility is UI state in `settings.json`: the Library has one hidden-column -list, and playlists have independent lists keyed by Spotify playlist ID. +Column layout is UI state in `settings.json`: the Library has one order, width map, +and hidden-column list. Playlists have independent metadata-column order, width, +and visibility overrides keyed by Spotify playlist ID; absent playlist keys mean +the default layout (fixed Spotify order `#`, Song, Artist, Album, Time, Rating, +Plays, Genre). The legacy `playlistHiddenColumns` map remains readable and +portable. Restoring a playlist aspect to its default removes that playlist's +override instead of storing redundant defaults. ## Spotify audio cache @@ -67,6 +86,31 @@ development token file. On Unix, Retune creates and checks that file with mode 0600. Ordinary release bundles never enable that feature and retain the encrypted-file/native credential-store boundary. +Last.fm release session keys use the native credential store with service +`com.rianjs.retune` and account `lastfm-session`; the username is stored beside +that credential value and is never sent to the frontend except as connected +account display state. Debug builds and local bundles use +`dev-lastfm-session.json`. Authorization request tokens use the short-lived +owner-only pending-token file. Scrobbles are written atomically oldest-first to +the ordered queue file, sent in batches of at most 50, and removed after an +accepted/ignored response (including ignored code 3). The queue is +machine/account state and is intentionally omitted from backup and restore; +each queued scrobble carries its non-secret owning Last.fm username. Session and +app-identity failures preserve it for reconnect, while permanent request +rejections remove and log the affected batch. Reconnecting as the same username +preserves and drains the queue; a different username clears it durably before +installing the new session. Ownerless or mixed legacy queues are never flushed +and are cleared during account reconciliation. Disconnect clears the durable +queue before clearing session or pending authorization state; a failed queue +clear leaves the active account connected. + +Last.fm runtime mutations snapshot queue/account state under the persistence +serialization mutex, perform filesystem and credential-store work in blocking +tasks, and commit only when the snapshot is still current. This keeps queue +ordering and account isolation intact without holding the async runtime mutex +across local I/O; failed queue writes or clears retain the corresponding +in-memory state. + ## Recovery and portability A corrupt library file is quarantined with a timestamped `.corrupt-*` suffix; diff --git a/docs/architecture/playback.md b/docs/architecture/playback.md index 658bf7b..c0d7f48 100644 --- a/docs/architecture/playback.md +++ b/docs/architecture/playback.md @@ -19,11 +19,17 @@ Ordinary queues omit overlay tracks disabled by the user. Explicitly starting a disabled track includes that track for the run while later advancement still skips the other disabled tracks. -When a resolved Library view contains the current track, it replaces the -controller's queue without reloading that track. Repeat off continues through -the rows below it, repeat all may wrap to rows above it, and repeat one remains -on the current track. Views that do not contain the current track leave the -active queue unchanged. +Navigation and resolved Library projections are view-only; they never mutate or +replace the active queue. An explicit Library, playlist, or other play/start +action establishes the canonical queue and current position. Repeat off stops +at that queue's end, repeat all may wrap, and repeat one remains on the current +track. + +The reducer emits only neutral, listening-generation-scoped facts: natural +start, cumulative forward listening, discontinuity/seek, and completion. The +shell translates those facts into provider actions. Last.fm owns its +`scrobble_threshold_ms` eligibility, provider state, timestamps, and queueing; +the playback reducer has no Last.fm dependency or scrobble policy. ## Backends @@ -108,6 +114,18 @@ tracks. Skipping before the threshold does not increment the count. Playback events are generation-scoped so late backend events cannot count or advance a newer track. +The same generation-scoped reducer emits neutral listening facts when playback +starts, advances, seeks, or completes; it does not know about Last.fm or its +thresholds. The Tauri shell and Last.fm service track cumulative forward time, +use the original start timestamp, and decide eligibility for tracks longer than +30 seconds at `min(duration / 2, 240 seconds)`. Explicit seeks, discontinuous +position jumps, and stale backend events do not advance that listening total, +while completion is a fallback when no immediate eligibility decision was +observed. The shell handles Last.fm HTTPS, credential storage, queue +persistence, and retries for built-in Spotify, Spotify Connect, and local/tagged +overlay playback. Disabling scrobbling stops new requests and flushing without +deleting queued items; reconnecting or re-enabling drains the queue. + ## Local files `retune-audio` recursively scans supported audio extensions without following diff --git a/scripts/build-install.sh b/scripts/build-install.sh index 58ddfbb..d8d5f64 100755 --- a/scripts/build-install.sh +++ b/scripts/build-install.sh @@ -11,12 +11,26 @@ export PATH="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH" cd "$repo/apps/desktop" # --bundles app: skip the DMG step (slow, drives Finder via AppleScript, # and we install straight into /Applications anyway). +lastfm_env="$repo/.env.lastfm.local" +if [ -f "$lastfm_env" ]; then + mode="$(stat -f '%Lp' "$lastfm_env")" + if [ "$mode" != 600 ]; then + chmod 600 "$lastfm_env" + fi + set -a + # shellcheck disable=SC1090 + . "$lastfm_env" + set +a +else + echo "::warning::.env.lastfm.local is missing; this local build will not include Last.fm." >&2 +fi npx tauri build --bundles app --features dev-token-store +unset RETUNE_LASTFM_API_KEY RETUNE_LASTFM_SHARED_SECRET # Tauri ad-hoc signs the bundle (bundle.macOS.signingIdentity is "-" in # tauri.conf.json — same as release builds). Fail loudly if it didn't. codesign --verify --deep --strict "$bundle" -codesign -dv "$bundle" 2>&1 | grep -q "Signature=adhoc" \ +codesign -dv "$bundle" 2>&1 | grep -F "Signature=adhoc" >/dev/null \ || { echo "Expected an ad-hoc signature on $bundle" >&2; exit 1; } # Quit a running copy before overwriting it, then install fresh. diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 6613d6e..a3d42c5 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -12,7 +12,17 @@ const desktopCargo = read('apps/desktop/src-tauri/Cargo.toml') const lock = read('Cargo.lock') const workflow = read('.github/workflows/release.yml') const ci = read('.github/workflows/ci.yml') +const gitignore = read('.gitignore') const cask = read('packaging/homebrew/retune.rb.template') +const buildInstall = read('scripts/build-install.sh') +const lastfmBackend = read('apps/desktop/src-tauri/src/lastfm.rs') +const frontendState = [ + read('apps/desktop/src/App.tsx'), + read('apps/desktop/src/dialogViews.tsx'), + read('apps/desktop/src/types.ts'), +].join('\n') + +const nativeBundleStep = workflow.match(/- name: Build native bundle\n[\s\S]*?(?=\n - name:)/)?.[0] ?? '' const cargoVersion = desktopCargo.match(/name = "retune-desktop"\s+version = "([^"]+)"/s)?.[1] const lockVersion = lock.match(/\[\[package\]\]\s+name = "retune-desktop"\s+version = "([^"]+)"/s)?.[1] @@ -24,6 +34,18 @@ assert.equal(tauri.bundle.linux.deb.section, 'sound') required(workflow, 'workflow_dispatch:') required(workflow, 'tags:\n - "v*"') +required(nativeBundleStep, 'RETUNE_LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }}', 'trusted Last.fm API key mapping') +required(nativeBundleStep, 'RETUNE_LASTFM_SHARED_SECRET: ${{ secrets.LASTFM_API_SECRET }}', 'trusted Last.fm shared-secret mapping') +required(nativeBundleStep, "RETUNE_LASTFM_API_KEY', 'RETUNE_LASTFM_SHARED_SECRET", 'release Last.fm credential presence check') +assert.doesNotMatch(ci, /LASTFM_API_KEY|LASTFM_API_SECRET|RETUNE_LASTFM/) +assert.equal((workflow.match(/secrets\.LASTFM_API_(?:KEY|SECRET)/g) ?? []).length, 2) +required(buildInstall, '.env.lastfm.local') +required(buildInstall, 'chmod 600') +required(buildInstall, 'unset RETUNE_LASTFM_API_KEY RETUNE_LASTFM_SHARED_SECRET') +required(gitignore, '.env.lastfm.local') +required(lastfmBackend, 'option_env!("RETUNE_LASTFM_API_KEY")', 'backend-only Last.fm API key compile option') +required(lastfmBackend, 'option_env!("RETUNE_LASTFM_SHARED_SECRET")', 'backend-only Last.fm shared-secret compile option') +assert.doesNotMatch(frontendState, /RETUNE_LASTFM_API_KEY|RETUNE_LASTFM_SHARED_SECRET|LASTFM_API_SECRET|LASTFM_API_KEY/) for (const [runner, arch, bundle] of [ ['macos-15', 'arm64', 'app'], ['windows-2025', 'x64', 'nsis'],