diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 22157ef30..8360d554a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -719,6 +719,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -742,9 +752,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -755,7 +765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1353,6 +1363,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1360,7 +1379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1374,6 +1393,12 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1840,6 +1865,25 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1930,11 +1974,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2021,6 +2065,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2033,6 +2078,38 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2051,9 +2128,11 @@ dependencies = [ "percent-encoding 2.3.2", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2651,6 +2730,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -2762,10 +2847,12 @@ dependencies = [ "lofty", "log", "m3u", + "md5", "memoize", "nosleep", "pathdiff", "rayon", + "reqwest 0.12.28", "serde", "serde_json", "sqlx", @@ -2792,6 +2879,23 @@ dependencies = [ "walkdir", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2943,9 +3047,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -3305,6 +3409,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4087,6 +4235,46 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding 2.3.2", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url 2.5.8", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -4145,6 +4333,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -4232,6 +4434,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4253,6 +4488,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -4316,6 +4560,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -5068,6 +5335,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -5089,7 +5377,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -5176,7 +5464,7 @@ dependencies = [ "percent-encoding 2.3.2", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -5692,9 +5980,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -5709,15 +5997,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5776,6 +6064,26 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -6138,6 +6446,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "1.7.2" @@ -6283,11 +6597,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen 0.46.0", + "wit-bindgen", ] [[package]] @@ -6296,7 +6610,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -6749,6 +7063,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6803,6 +7128,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -7149,12 +7483,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3a28bf0e2..3864eeb60 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,10 +43,12 @@ itertools = "0.14.0" log = "0.4.29" lofty = "0.23.2" m3u = "1.0.0" +md5 = "0.7.0" memoize = "0.6.0" nosleep = "0.2.1" pathdiff = "0.2.3" rayon = "1.11.0" +reqwest = { version = "0.12.15", features = ["json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "macros"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 56f88a4c2..e26fa16f2 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -44,6 +44,18 @@ fn main() { "default-view", tauri_build::InlinedPlugin::new().commands(&["set"]), ) + .plugin( + + "lastfm", + tauri_build::InlinedPlugin::new().commands(&[ + "lastfm_get_auth_url", + "lastfm_get_session", + "lastfm_disconnect", + "lastfm_now_playing", + "lastfm_scrobble", + "lastfm_test_connection", + ]), + ) .plugin( "sleepblocker", tauri_build::InlinedPlugin::new().commands(&["enable", "disable"]), diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 562190054..8fe7bcaa7 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -48,6 +48,12 @@ "database:allow-delete-playlist", "database:allow-reset", "default-view:allow-set", + "lastfm:allow-lastfm-get-auth-url", + "lastfm:allow-lastfm-get-session", + "lastfm:allow-lastfm-disconnect", + "lastfm:allow-lastfm-now-playing", + "lastfm:allow-lastfm-scrobble", + "lastfm:allow-lastfm-test-connection", "sleepblocker:allow-enable", "sleepblocker:allow-disable" ], diff --git a/src-tauri/src/libs/error.rs b/src-tauri/src/libs/error.rs index 12392d4ab..9555f124c 100644 --- a/src-tauri/src/libs/error.rs +++ b/src-tauri/src/libs/error.rs @@ -51,6 +51,9 @@ pub enum MuseeksError { #[error("Failed to find ID3 tags for path: {0}")] ID3NoTags(PathBuf), + + #[error("Last.fm error: {0}")] + LastFm(String), } /** diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1069f4b23..57c030c96 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -75,6 +75,7 @@ fn main() { .plugin(plugins::debug::init()) .plugin(plugins::default_view::init()) .plugin(plugins::file_associations::init()) + .plugin(plugins::lastfm::init()) .plugin(plugins::sleepblocker::init()) // Tauri integrations with the Operating System .plugin(tauri_plugin_clipboard_manager::init()) diff --git a/src-tauri/src/plugins/config.rs b/src-tauri/src/plugins/config.rs index 97bed2563..0cdff2347 100644 --- a/src-tauri/src/plugins/config.rs +++ b/src-tauri/src/plugins/config.rs @@ -76,6 +76,9 @@ pub struct Config { pub notifications: bool, pub track_view_density: TrackViewDensity, pub wayland_compat: bool, + pub lastfm_enabled: bool, + pub lastfm_session_key: Option, + pub lastfm_username: Option, } pub const SYSTEM_THEME: &str = "__system"; @@ -106,6 +109,9 @@ impl Config { notifications: false, track_view_density: TrackViewDensity::Normal, wayland_compat: false, + lastfm_enabled: false, + lastfm_session_key: None, + lastfm_username: None, } } } diff --git a/src-tauri/src/plugins/lastfm.rs b/src-tauri/src/plugins/lastfm.rs new file mode 100644 index 000000000..5bc7edea7 --- /dev/null +++ b/src-tauri/src/plugins/lastfm.rs @@ -0,0 +1,410 @@ +/** + * Last.fm integration plugin + * Handles authentication and API communication with Last.fm + */ +use log::{error, info}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tauri::plugin::{Builder, TauriPlugin}; +use tauri::{Runtime, State}; +use ts_rs::TS; + +use crate::libs::error::{AnyResult, MuseeksError}; +use crate::plugins::config::ConfigManager; + +// Last.fm API credentials - these are public identifiers for the Museeks app +// Register your app at https://www.last.fm/api/account/create +const API_KEY: &str = "6496d20a201157d8c0c86cde0f2df5db"; // TODO: Replace with actual API key +const API_SECRET: &str = "d4f4b99472e12f395744134fba2c6d27"; // TODO: Replace with actual API secret +const API_ROOT: &str = "https://ws.audioscrobbler.com/2.0/"; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/generated/typings.ts")] +pub struct LastfmAuthUrl { + pub url: String, + pub token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/generated/typings.ts")] +pub struct LastfmUser { + pub username: String, + pub session_key: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LastfmResponse { + #[serde(flatten)] + data: Option, + error: Option, + message: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct TokenResponse { + token: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SessionResponse { + session: SessionData, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SessionData { + name: String, + key: String, +} + +/** + * Generate MD5 signature for Last.fm API calls + * Required for write operations (authentication, scrobbling) + */ +fn generate_signature(params: &HashMap) -> String { + let mut sorted_params: Vec<_> = params.iter().collect(); + sorted_params.sort_by_key(|a| a.0); + + let mut signature_string = String::new(); + for (key, value) in sorted_params { + signature_string.push_str(key); + signature_string.push_str(value); + } + signature_string.push_str(API_SECRET); + + format!("{:x}", md5::compute(signature_string)) +} + +/** + * Step 1: Get an authentication token and URL for the user to authorize + */ +#[tauri::command] +pub async fn lastfm_get_auth_url() -> AnyResult { + info!("Requesting Last.fm authentication token"); + + let client = Client::new(); + let mut params = HashMap::new(); + params.insert("method".to_string(), "auth.getToken".to_string()); + params.insert("api_key".to_string(), API_KEY.to_string()); + + let api_sig = generate_signature(¶ms); + params.insert("api_sig".to_string(), api_sig); + params.insert("format".to_string(), "json".to_string()); + + let response = client + .get(API_ROOT) + .query(¶ms) + .send() + .await + .map_err(|e| MuseeksError::LastFm(format!("Failed to get auth token: {}", e)))?; + + let json: LastfmResponse = response + .json() + .await + .map_err(|e| MuseeksError::LastFm(format!("Failed to parse token response: {}", e)))?; + + if let Some(error_code) = json.error { + return Err(MuseeksError::LastFm(format!( + "Last.fm API error {}: {}", + error_code, + json.message.unwrap_or_default() + ))); + } + + let token = json + .data + .ok_or_else(|| MuseeksError::LastFm("No token in response".to_string()))? + .token; + + let auth_url = format!( + "https://www.last.fm/api/auth/?api_key={}&token={}", + API_KEY, token + ); + + Ok(LastfmAuthUrl { + url: auth_url, + token, + }) +} + +/** + * Step 2: Exchange the authorized token for a session key + */ +#[tauri::command] +pub async fn lastfm_get_session( + token: String, + config_manager: State<'_, ConfigManager>, +) -> AnyResult { + info!("Exchanging Last.fm token for session key"); + + let client = Client::new(); + let mut params = HashMap::new(); + params.insert("method".to_string(), "auth.getSession".to_string()); + params.insert("api_key".to_string(), API_KEY.to_string()); + params.insert("token".to_string(), token.clone()); + + let api_sig = generate_signature(¶ms); + params.insert("api_sig".to_string(), api_sig); + params.insert("format".to_string(), "json".to_string()); + + let response = client + .get(API_ROOT) + .query(¶ms) + .send() + .await + .map_err(|e| MuseeksError::LastFm(format!("Failed to get session: {}", e)))?; + + let json: LastfmResponse = response + .json() + .await + .map_err(|e| MuseeksError::LastFm(format!("Failed to parse session response: {}", e)))?; + + if let Some(error_code) = json.error { + return Err(MuseeksError::LastFm(format!( + "Last.fm API error {}: {}", + error_code, + json.message.unwrap_or_default() + ))); + } + + let session_data = json + .data + .ok_or_else(|| MuseeksError::LastFm("No session in response".to_string()))? + .session; + + // Save to config + let mut config = config_manager.get()?; + config.lastfm_enabled = true; + config.lastfm_session_key = Some(session_data.key.clone()); + config.lastfm_username = Some(session_data.name.clone()); + config_manager.update(config)?; + + info!( + "Last.fm authentication successful for user: {}", + session_data.name + ); + + Ok(LastfmUser { + username: session_data.name, + session_key: session_data.key, + }) +} + +/** + * Disconnect from Last.fm (clear session) + */ +#[tauri::command] +pub async fn lastfm_disconnect(config_manager: State<'_, ConfigManager>) -> AnyResult<()> { + info!("Disconnecting from Last.fm"); + + let mut config = config_manager.get()?; + config.lastfm_enabled = false; + config.lastfm_session_key = None; + config.lastfm_username = None; + config_manager.update(config)?; + + Ok(()) +} + +/** + * Test the current Last.fm connection + */ +#[tauri::command] +pub async fn lastfm_test_connection(config_manager: State<'_, ConfigManager>) -> AnyResult { + let config = config_manager.get()?; + + if !config.lastfm_enabled { + return Ok(false); + } + + let session_key = match &config.lastfm_session_key { + Some(key) => key, + None => return Ok(false), + }; + + let client = Client::new(); + let mut params = HashMap::new(); + params.insert("method".to_string(), "user.getInfo".to_string()); + params.insert("api_key".to_string(), API_KEY.to_string()); + params.insert("sk".to_string(), session_key.clone()); + + let api_sig = generate_signature(¶ms); + params.insert("api_sig".to_string(), api_sig); + params.insert("format".to_string(), "json".to_string()); + + let response = client + .get(API_ROOT) + .query(¶ms) + .send() + .await + .map_err(|e| { + error!("Last.fm connection test failed: {}", e); + MuseeksError::LastFm(format!("Connection test failed: {}", e)) + })?; + + let json: LastfmResponse = response + .json() + .await + .map_err(|e| MuseeksError::LastFm(format!("Failed to parse response: {}", e)))?; + + Ok(json.error.is_none()) +} + +/** + * Update "Now Playing" status on Last.fm + * Should be called when a track starts playing + */ +#[tauri::command] +pub async fn lastfm_now_playing( + artist: String, + track: String, + album: Option, + duration: Option, + config_manager: State<'_, ConfigManager>, +) -> AnyResult<()> { + let config = config_manager.get()?; + + if !config.lastfm_enabled { + return Ok(()); + } + + let session_key = match &config.lastfm_session_key { + Some(key) => key, + None => return Ok(()), + }; + + info!("Updating Last.fm Now Playing: {} - {}", artist, track); + + let client = Client::new(); + let mut params = HashMap::new(); + params.insert("method".to_string(), "track.updateNowPlaying".to_string()); + params.insert("api_key".to_string(), API_KEY.to_string()); + params.insert("sk".to_string(), session_key.clone()); + params.insert("artist".to_string(), artist); + params.insert("track".to_string(), track); + + if let Some(album_name) = album { + params.insert("album".to_string(), album_name); + } + + if let Some(dur) = duration { + params.insert("duration".to_string(), dur.to_string()); + } + + let api_sig = generate_signature(¶ms); + params.insert("api_sig".to_string(), api_sig); + params.insert("format".to_string(), "json".to_string()); + + let response = client + .post(API_ROOT) + .form(¶ms) + .send() + .await + .map_err(|e| { + error!("Failed to update Now Playing: {}", e); + MuseeksError::LastFm(format!("Failed to update Now Playing: {}", e)) + })?; + + let json: LastfmResponse = response + .json() + .await + .map_err(|e| MuseeksError::LastFm(format!("Failed to parse response: {}", e)))?; + + if let Some(error_code) = json.error { + error!( + "Last.fm Now Playing error {}: {}", + error_code, + json.message.unwrap_or_default() + ); + } + + Ok(()) +} + +/** + * Scrobble a track to Last.fm + * Should be called when a track has been played for at least 50% or 4 minutes + */ +#[tauri::command] +pub async fn lastfm_scrobble( + artist: String, + track: String, + timestamp: u64, // Unix timestamp when playback started + album: Option, + duration: Option, + config_manager: State<'_, ConfigManager>, +) -> AnyResult<()> { + let config = config_manager.get()?; + + if !config.lastfm_enabled { + return Ok(()); + } + + let session_key = match &config.lastfm_session_key { + Some(key) => key, + None => return Ok(()), + }; + + info!("Scrobbling to Last.fm: {} - {}", artist, track); + + let client = Client::new(); + let mut params = HashMap::new(); + params.insert("method".to_string(), "track.scrobble".to_string()); + params.insert("api_key".to_string(), API_KEY.to_string()); + params.insert("sk".to_string(), session_key.clone()); + params.insert("artist".to_string(), artist); + params.insert("track".to_string(), track); + params.insert("timestamp".to_string(), timestamp.to_string()); + + if let Some(album_name) = album { + params.insert("album".to_string(), album_name); + } + + if let Some(dur) = duration { + params.insert("duration".to_string(), dur.to_string()); + } + + let api_sig = generate_signature(¶ms); + params.insert("api_sig".to_string(), api_sig); + params.insert("format".to_string(), "json".to_string()); + + let response = client + .post(API_ROOT) + .form(¶ms) + .send() + .await + .map_err(|e| { + error!("Failed to scrobble: {}", e); + MuseeksError::LastFm(format!("Failed to scrobble: {}", e)) + })?; + + let json: LastfmResponse = response + .json() + .await + .map_err(|e| MuseeksError::LastFm(format!("Failed to parse response: {}", e)))?; + + if let Some(error_code) = json.error { + let message = json.message.unwrap_or_default(); + error!("Last.fm scrobble error {}: {}", error_code, message); + return Err(MuseeksError::LastFm(format!( + "Scrobble failed: {}", + message + ))); + } + + info!("Successfully scrobbled track"); + Ok(()) +} + +pub fn init() -> TauriPlugin { + Builder::::new("lastfm") + .invoke_handler(tauri::generate_handler![ + lastfm_get_auth_url, + lastfm_get_session, + lastfm_disconnect, + lastfm_test_connection, + lastfm_now_playing, + lastfm_scrobble, + ]) + .build() +} diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 0c2f90b52..d9f21027d 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -24,5 +24,6 @@ pub mod db; * Settings-related plugins */ pub mod default_view; +pub mod lastfm; pub mod sleepblocker; pub mod stream_server; diff --git a/src/components/PlayerEvents.tsx b/src/components/PlayerEvents.tsx index 85a61a5b4..53ea60deb 100644 --- a/src/components/PlayerEvents.tsx +++ b/src/components/PlayerEvents.tsx @@ -8,6 +8,7 @@ import ConfigBridge from '../lib/bridge-config'; import { getCover } from '../lib/cover'; import player from '../lib/player'; import { goToPlayingTrack } from '../lib/queue-origin'; +import scrobbler from '../lib/scrobbler'; import { logAndNotifyError } from '../lib/utils'; import { useToastsAPI } from '../stores/useToastsStore'; @@ -28,6 +29,9 @@ function PlayerEvents() { const navigate = useNavigate(); useEffect(() => { + // Initialize the scrobbler to track playback and send to Last.fm + scrobbler.init(); + function handleAudioError(error: MediaError) { player.stop(); diff --git a/src/generated/route-tree.ts b/src/generated/route-tree.ts index 3238f7e53..e9bebff99 100644 --- a/src/generated/route-tree.ts +++ b/src/generated/route-tree.ts @@ -15,6 +15,7 @@ import { Route as LibraryRouteImport } from './../routes/library' import { Route as ArtistsRouteImport } from './../routes/artists' import { Route as TracksTrackIDRouteImport } from './../routes/tracks.$trackID' import { Route as SettingsUiRouteImport } from './../routes/settings.ui' +import { Route as SettingsScrobblingRouteImport } from './../routes/settings.scrobbling' import { Route as SettingsLibraryRouteImport } from './../routes/settings.library' import { Route as SettingsAudioRouteImport } from './../routes/settings.audio' import { Route as SettingsAboutRouteImport } from './../routes/settings.about' @@ -51,6 +52,11 @@ const SettingsUiRoute = SettingsUiRouteImport.update({ path: '/ui', getParentRoute: () => SettingsRoute, } as any) +const SettingsScrobblingRoute = SettingsScrobblingRouteImport.update({ + id: '/scrobbling', + path: '/scrobbling', + getParentRoute: () => SettingsRoute, +} as any) const SettingsLibraryRoute = SettingsLibraryRouteImport.update({ id: '/library', path: '/library', @@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { '/settings/about': typeof SettingsAboutRoute '/settings/audio': typeof SettingsAudioRoute '/settings/library': typeof SettingsLibraryRoute + '/settings/scrobbling': typeof SettingsScrobblingRoute '/settings/ui': typeof SettingsUiRoute '/tracks/$trackID': typeof TracksTrackIDRoute } @@ -100,6 +107,7 @@ export interface FileRoutesByTo { '/settings/about': typeof SettingsAboutRoute '/settings/audio': typeof SettingsAudioRoute '/settings/library': typeof SettingsLibraryRoute + '/settings/scrobbling': typeof SettingsScrobblingRoute '/settings/ui': typeof SettingsUiRoute '/tracks/$trackID': typeof TracksTrackIDRoute } @@ -114,6 +122,7 @@ export interface FileRoutesById { '/settings/about': typeof SettingsAboutRoute '/settings/audio': typeof SettingsAudioRoute '/settings/library': typeof SettingsLibraryRoute + '/settings/scrobbling': typeof SettingsScrobblingRoute '/settings/ui': typeof SettingsUiRoute '/tracks/$trackID': typeof TracksTrackIDRoute } @@ -129,6 +138,7 @@ export interface FileRouteTypes { | '/settings/about' | '/settings/audio' | '/settings/library' + | '/settings/scrobbling' | '/settings/ui' | '/tracks/$trackID' fileRoutesByTo: FileRoutesByTo @@ -142,6 +152,7 @@ export interface FileRouteTypes { | '/settings/about' | '/settings/audio' | '/settings/library' + | '/settings/scrobbling' | '/settings/ui' | '/tracks/$trackID' id: @@ -155,6 +166,7 @@ export interface FileRouteTypes { | '/settings/about' | '/settings/audio' | '/settings/library' + | '/settings/scrobbling' | '/settings/ui' | '/tracks/$trackID' fileRoutesById: FileRoutesById @@ -211,6 +223,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsUiRouteImport parentRoute: typeof SettingsRoute } + '/settings/scrobbling': { + id: '/settings/scrobbling' + path: '/scrobbling' + fullPath: '/settings/scrobbling' + preLoaderRoute: typeof SettingsScrobblingRouteImport + parentRoute: typeof SettingsRoute + } '/settings/library': { id: '/settings/library' path: '/library' @@ -276,6 +295,7 @@ interface SettingsRouteChildren { SettingsAboutRoute: typeof SettingsAboutRoute SettingsAudioRoute: typeof SettingsAudioRoute SettingsLibraryRoute: typeof SettingsLibraryRoute + SettingsScrobblingRoute: typeof SettingsScrobblingRoute SettingsUiRoute: typeof SettingsUiRoute } @@ -283,6 +303,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsAboutRoute: SettingsAboutRoute, SettingsAudioRoute: SettingsAudioRoute, SettingsLibraryRoute: SettingsLibraryRoute, + SettingsScrobblingRoute: SettingsScrobblingRoute, SettingsUiRoute: SettingsUiRoute, } diff --git a/src/generated/typings.ts b/src/generated/typings.ts index 5a4f258c9..66ad6b01a 100644 --- a/src/generated/typings.ts +++ b/src/generated/typings.ts @@ -1,11 +1,15 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Config = { language: string, theme: string, ui_accent_color: string | null, audio_volume: number, audio_playback_rate: number | null, audio_follow_playing_track: boolean, audio_muted: boolean, audio_shuffle: boolean, audio_repeat: Repeat, audio_stream_server: boolean, default_view: DefaultView, library_sort_by: SortBy, library_sort_order: SortOrder, library_folders: Array, library_autorefresh: boolean, sleepblocker: boolean, auto_update_checker: boolean, notifications: boolean, track_view_density: TrackViewDensity, wayland_compat: boolean, }; +export type Config = { language: string, theme: string, ui_accent_color: string | null, audio_volume: number, audio_playback_rate: number | null, audio_follow_playing_track: boolean, audio_muted: boolean, audio_shuffle: boolean, audio_repeat: Repeat, audio_stream_server: boolean, default_view: DefaultView, library_sort_by: SortBy, library_sort_order: SortOrder, library_folders: Array, library_autorefresh: boolean, sleepblocker: boolean, auto_update_checker: boolean, notifications: boolean, track_view_density: TrackViewDensity, wayland_compat: boolean, lastfm_enabled: boolean, lastfm_session_key: string | null, lastfm_username: string | null, }; export type DefaultView = "Library" | "Artists" | "Playlists"; export type IPCEvent = { "Unknown": string } | "PlaybackPlay" | "PlaybackPause" | "PlaybackStop" | "PlaybackPlayPause" | "PlaybackPrevious" | "PlaybackNext" | "PlaybackStart" | "LibraryScanProgress" | "GoToLibrary" | "GoToPlaylists" | "GoToSettings" | "JumpToPlayingTrack"; +export type LastfmAuthUrl = { url: string, token: string, }; + +export type LastfmUser = { username: string, session_key: string, }; + /** ---------------------------------------------------------------------------- * Playlist * represent a playlist, that has a name and a list of tracks diff --git a/src/lib/bridge-lastfm.ts b/src/lib/bridge-lastfm.ts new file mode 100644 index 000000000..014c4d18b --- /dev/null +++ b/src/lib/bridge-lastfm.ts @@ -0,0 +1,109 @@ +import { invoke } from '@tauri-apps/api/core'; + +import type { LastfmAuthUrl, LastfmUser } from '../generated/typings'; +import { logAndNotifyError } from './utils'; + +/** + * Last.fm Bridge for the UI to communicate with the backend. + * Handles authentication and API communication with Last.fm. + */ +const LastfmBridge = { + /** + * Step 1: Get authentication URL to open in browser + * Returns the URL and token needed for authorization + */ + async getAuthUrl(): Promise { + try { + return await invoke('plugin:lastfm|lastfm_get_auth_url'); + } catch (err) { + logAndNotifyError(err); + throw err; + } + }, + + /** + * Step 2: Exchange token for session key after user authorizes + * This should be called after the user has authorized the app on Last.fm + */ + async getSession(token: string): Promise { + try { + return await invoke('plugin:lastfm|lastfm_get_session', { token }); + } catch (err) { + logAndNotifyError(err); + throw err; + } + }, + + /** + * Disconnect from Last.fm (clear session) + */ + async disconnect(): Promise { + try { + await invoke('plugin:lastfm|lastfm_disconnect'); + } catch (err) { + logAndNotifyError(err); + throw err; + } + }, + + /** + * Test the current Last.fm connection + * Returns true if connected and session is valid + */ + async testConnection(): Promise { + try { + return await invoke('plugin:lastfm|lastfm_test_connection'); + } catch (err) { + logAndNotifyError(err); + return false; + } + }, + + /** + * Update "Now Playing" status on Last.fm + * Should be called when a track starts playing + */ + async nowPlaying( + artist: string, + track: string, + album?: string, + duration?: number, + ): Promise { + try { + await invoke('plugin:lastfm|lastfm_now_playing', { + artist, + track, + album: album ?? null, + duration: duration ?? null, + }); + } catch (err) { + logAndNotifyError(err); + } + }, + + /** + * Scrobble a track to Last.fm + * Should be called when a track has been played for at least 50% or 4 minutes + */ + async scrobble( + artist: string, + track: string, + timestamp: number, + album?: string, + duration?: number, + ): Promise { + try { + await invoke('plugin:lastfm|lastfm_scrobble', { + artist, + track, + timestamp, + album: album ?? null, + duration: duration ?? null, + }); + } catch (err) { + logAndNotifyError(err); + } + }, +}; + +export default LastfmBridge; diff --git a/src/lib/scrobbler.ts b/src/lib/scrobbler.ts new file mode 100644 index 000000000..23fcdb2b9 --- /dev/null +++ b/src/lib/scrobbler.ts @@ -0,0 +1,180 @@ +/** + * Scrobbler service + * Handles Last.fm scrobbling logic according to the Last.fm specifications: + * - Update "Now Playing" when a track starts + * - Scrobble when track has been played for at least: + * - 50% of the track duration OR + * - 4 minutes (whichever comes first) + * - Tracks must be longer than 30 seconds to be scrobbled + */ + +import type { Track } from '../generated/typings'; +import LastfmBridge from './bridge-lastfm'; +import player from './player'; + +const MIN_SCROBBLE_DURATION = 30; // seconds +const SCROBBLE_TIME_THRESHOLD = 240; // 4 minutes in seconds + +interface ScrobbleState { + track: Track | null; + startTime: number | null; + startTimestamp: number | null; // Unix timestamp + hasScrobbled: boolean; + scrobbleTimeReached: boolean; +} + +class Scrobbler { + private state: ScrobbleState = { + track: null, + startTime: null, + startTimestamp: null, + hasScrobbled: false, + scrobbleTimeReached: false, + }; + + /** + * Initialize the scrobbler and attach event listeners to the player + */ + init() { + // When a new track starts playing + player.on('trackChange', this.handleTrackChange.bind(this)); + + // Monitor playback time + player.on('timeupdate', this.handleTimeUpdate.bind(this)); + + // Handle playback stop/pause + player.on('pause', this.handlePause.bind(this)); + player.on('stop', this.handleStop.bind(this)); + player.on('ended', this.handleEnded.bind(this)); + } + + /** + * Handle track change - update Now Playing + */ + private handleTrackChange(track: Track | null) { + // Reset state for new track + this.state = { + track, + startTime: null, + startTimestamp: null, + hasScrobbled: false, + scrobbleTimeReached: false, + }; + + if (!track) { + return; + } + + // Don't scrobble tracks shorter than 30 seconds + if (track.duration < MIN_SCROBBLE_DURATION) { + return; + } + + // Record when playback started + this.state.startTime = Date.now(); + this.state.startTimestamp = Math.floor(Date.now() / 1000); + + // Update Now Playing on Last.fm + this.updateNowPlaying(track); + } + + /** + * Handle time updates - check if we should scrobble + */ + private handleTimeUpdate(currentTime: number) { + if (!this.state.track || this.state.hasScrobbled || !this.state.startTime) { + return; + } + + const track = this.state.track; + const playedDuration = currentTime; + + // Calculate scrobble threshold + // Scrobble at 50% of track duration OR 4 minutes, whichever comes first + const halfDuration = track.duration / 2; + const scrobbleThreshold = Math.min(halfDuration, SCROBBLE_TIME_THRESHOLD); + + // Check if we've reached the scrobble point + if (playedDuration >= scrobbleThreshold && !this.state.scrobbleTimeReached) { + this.state.scrobbleTimeReached = true; + this.scrobbleTrack(track); + } + } + + /** + * Handle pause - mark as scrobbled if we've reached the threshold + */ + private handlePause() { + // If we've reached the scrobble time but haven't scrobbled yet (edge case) + if ( + this.state.track && + this.state.scrobbleTimeReached && + !this.state.hasScrobbled + ) { + this.scrobbleTrack(this.state.track); + } + } + + /** + * Handle stop - reset state + */ + private handleStop() { + this.state = { + track: null, + startTime: null, + startTimestamp: null, + hasScrobbled: false, + scrobbleTimeReached: false, + }; + } + + /** + * Handle track ended - scrobble if we haven't already + */ + private handleEnded() { + if ( + this.state.track && + !this.state.hasScrobbled && + this.state.track.duration >= MIN_SCROBBLE_DURATION + ) { + // Track ended naturally, scrobble it if we haven't already + this.scrobbleTrack(this.state.track); + } + } + + /** + * Update "Now Playing" status on Last.fm + */ + private async updateNowPlaying(track: Track) { + const artist = track.artists[0] || track.album_artist || 'Unknown Artist'; + const title = track.title || 'Unknown Title'; + const album = track.album || undefined; + const duration = track.duration; + + await LastfmBridge.nowPlaying(artist, title, album, duration); + } + + /** + * Scrobble the track to Last.fm + */ + private async scrobbleTrack(track: Track) { + if (this.state.hasScrobbled || !this.state.startTimestamp) { + return; + } + + this.state.hasScrobbled = true; + + const artist = track.artists[0] || track.album_artist || 'Unknown Artist'; + const title = track.title || 'Unknown Title'; + const album = track.album || undefined; + const duration = track.duration; + const timestamp = this.state.startTimestamp; + + await LastfmBridge.scrobble(artist, title, timestamp, album, duration); + } +} + +// Create singleton instance +const scrobbler = new Scrobbler(); + +export default scrobbler; diff --git a/src/routes/settings.scrobbling.tsx b/src/routes/settings.scrobbling.tsx new file mode 100644 index 000000000..1ec6a8a27 --- /dev/null +++ b/src/routes/settings.scrobbling.tsx @@ -0,0 +1,152 @@ +import { Trans, useLingui } from '@lingui/react/macro'; +import { createFileRoute, useLoaderData } from '@tanstack/react-router'; +import { openUrl } from '@tauri-apps/plugin-opener'; +import { useState } from 'react'; + +import * as Setting from '../components/Setting'; +import CheckboxSetting from '../components/SettingCheckbox'; +import Button from '../elements/Button'; +import useInvalidate from '../hooks/useInvalidate'; +import ConfigBridge from '../lib/bridge-config'; +import LastfmBridge from '../lib/bridge-lastfm'; + +export const Route = createFileRoute('/settings/scrobbling')({ + component: ViewSettingsScrobbling, +}); + +function ViewSettingsScrobbling() { + const { config } = useLoaderData({ from: '/settings' }); + const { t } = useLingui(); + const invalidate = useInvalidate(); + + const [isConnecting, setIsConnecting] = useState(false); + const [authToken, setAuthToken] = useState(null); + const [error, setError] = useState(null); + + const handleConnect = async () => { + setIsConnecting(true); + setError(null); + + try { + const authData = await LastfmBridge.getAuthUrl(); + setAuthToken(authData.token); + + // Open the authorization URL in the user's browser + openUrl(authData.url); + } catch (err) { + setError(t`Failed to start Last.fm authentication. Please try again.`); + setIsConnecting(false); + } + }; + + const handleCompleteAuth = async () => { + if (!authToken) return; + + try { + await LastfmBridge.getSession(authToken); + setAuthToken(null); + setIsConnecting(false); + await invalidate(); + } catch (err) { + setError( + t`Failed to complete authentication. Make sure you authorized the app on Last.fm.`, + ); + } + }; + + const handleDisconnect = async () => { + try { + await LastfmBridge.disconnect(); + await invalidate(); + } catch (err) { + setError(t`Failed to disconnect from Last.fm.`); + } + }; + + const handleToggleEnabled = async (enabled: boolean) => { + await ConfigBridge.set('lastfm_enabled', enabled); + await invalidate(); + }; + + const isConnected = Boolean(config.lastfm_session_key); + + return ( + <> + + {t`Last.fm Integration`} + + + Connect your Last.fm account to scrobble tracks and share your + listening history. + + + + + {!isConnected && !isConnecting && ( + + + {error && {error}} + + )} + + {isConnecting && authToken && ( + + + + A browser window has been opened. Please authorize Museeks on + Last.fm, then click the button below to complete the connection. + + +
+ + +
+ {error && {error}} +
+ )} + + {isConnected && ( + <> + + + + Connected as {config.lastfm_username} + + + + + + + handleToggleEnabled(!config.lastfm_enabled)} + /> + + + )} + + ); +} diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index f1d437c40..8d22d2e66 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -47,6 +47,9 @@ function ViewSettings() { Interface + + Scrobbling + About diff --git a/src/translations/en.po b/src/translations/en.po index 2876ed87c..b29e968c9 100644 --- a/src/translations/en.po +++ b/src/translations/en.po @@ -195,6 +195,7 @@ msgstr "Remove tracks" #: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 +#: src/routes/settings.scrobbling.tsx:115 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "Cancel" @@ -437,6 +438,55 @@ msgstr "Reset" msgid "Reset library" msgstr "Reset library" +#: src/routes/settings.scrobbling.tsx:37 +msgid "Failed to start Last.fm authentication. Please try again." +msgstr "Failed to start Last.fm authentication. Please try again." + +#: src/routes/settings.scrobbling.tsx:52 +msgid "Failed to complete authentication. Make sure you authorized the app on Last.fm." +msgstr "Failed to complete authentication. Make sure you authorized the app on Last.fm." + +#: src/routes/settings.scrobbling.tsx:62 +msgid "Failed to disconnect from Last.fm." +msgstr "Failed to disconnect from Last.fm." + +#: src/routes/settings.scrobbling.tsx:76 +msgid "Last.fm Integration" +msgstr "Last.fm Integration" + +#: src/routes/settings.scrobbling.tsx:78 +msgid "Connect your Last.fm account to scrobble tracks and share your listening history." +msgstr "Connect your Last.fm account to scrobble tracks and share your listening history." + +#: src/routes/settings.scrobbling.tsx:88 +msgid "Connect to Last.fm" +msgstr "Connect to Last.fm" + +#: src/routes/settings.scrobbling.tsx:97 +msgid "A browser window has been opened. Please authorize Museeks on Last.fm, then click the button below to complete the connection." +msgstr "A browser window has been opened. Please authorize Museeks on Last.fm, then click the button below to complete the connection." + +#: src/routes/settings.scrobbling.tsx:104 +msgid "I've authorized Museeks" +msgstr "I've authorized Museeks" + +#. placeholder {0}: config.lastfm_username +#: src/routes/settings.scrobbling.tsx:126 +msgid "Connected as <0>{0}" +msgstr "Connected as <0>{0}" + +#: src/routes/settings.scrobbling.tsx:136 +msgid "Disconnect" +msgstr "Disconnect" + +#: src/routes/settings.scrobbling.tsx:142 +msgid "Enable scrobbling" +msgstr "Enable scrobbling" + +#: src/routes/settings.scrobbling.tsx:143 +msgid "Send your listening history to Last.fm" +msgstr "Send your listening history to Last.fm" + #: src/routes/settings.tsx:45 msgid "Audio" msgstr "Audio" @@ -446,6 +496,10 @@ msgid "Interface" msgstr "Interface" #: src/routes/settings.tsx:51 +msgid "Scrobbling" +msgstr "Scrobbling" + +#: src/routes/settings.tsx:54 msgid "About" msgstr "About" diff --git a/src/translations/es.po b/src/translations/es.po index db6075446..64cc450ba 100644 --- a/src/translations/es.po +++ b/src/translations/es.po @@ -195,6 +195,7 @@ msgstr "Eliminar pistas" #: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 +#: src/routes/settings.scrobbling.tsx:115 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "Cancelar" @@ -437,6 +438,55 @@ msgstr "Reiniciar" msgid "Reset library" msgstr "Reiniciar biblioteca" +#: src/routes/settings.scrobbling.tsx:37 +msgid "Failed to start Last.fm authentication. Please try again." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:52 +msgid "Failed to complete authentication. Make sure you authorized the app on Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:62 +msgid "Failed to disconnect from Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:76 +msgid "Last.fm Integration" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:78 +msgid "Connect your Last.fm account to scrobble tracks and share your listening history." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:88 +msgid "Connect to Last.fm" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:97 +msgid "A browser window has been opened. Please authorize Museeks on Last.fm, then click the button below to complete the connection." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:104 +msgid "I've authorized Museeks" +msgstr "" + +#. placeholder {0}: config.lastfm_username +#: src/routes/settings.scrobbling.tsx:126 +msgid "Connected as <0>{0}" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:136 +msgid "Disconnect" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:142 +msgid "Enable scrobbling" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:143 +msgid "Send your listening history to Last.fm" +msgstr "" + #: src/routes/settings.tsx:45 msgid "Audio" msgstr "Audio" @@ -446,6 +496,10 @@ msgid "Interface" msgstr "Interfaz" #: src/routes/settings.tsx:51 +msgid "Scrobbling" +msgstr "" + +#: src/routes/settings.tsx:54 msgid "About" msgstr "Acerca de" diff --git a/src/translations/fr.po b/src/translations/fr.po index 780e45d16..7470c8eb5 100644 --- a/src/translations/fr.po +++ b/src/translations/fr.po @@ -195,6 +195,7 @@ msgstr "Retirer les pistes" #: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 +#: src/routes/settings.scrobbling.tsx:115 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "Annuler" @@ -437,6 +438,55 @@ msgstr "Réinitialiser" msgid "Reset library" msgstr "Réinitialiser la bibliothèque" +#: src/routes/settings.scrobbling.tsx:37 +msgid "Failed to start Last.fm authentication. Please try again." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:52 +msgid "Failed to complete authentication. Make sure you authorized the app on Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:62 +msgid "Failed to disconnect from Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:76 +msgid "Last.fm Integration" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:78 +msgid "Connect your Last.fm account to scrobble tracks and share your listening history." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:88 +msgid "Connect to Last.fm" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:97 +msgid "A browser window has been opened. Please authorize Museeks on Last.fm, then click the button below to complete the connection." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:104 +msgid "I've authorized Museeks" +msgstr "" + +#. placeholder {0}: config.lastfm_username +#: src/routes/settings.scrobbling.tsx:126 +msgid "Connected as <0>{0}" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:136 +msgid "Disconnect" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:142 +msgid "Enable scrobbling" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:143 +msgid "Send your listening history to Last.fm" +msgstr "" + #: src/routes/settings.tsx:45 msgid "Audio" msgstr "Audio" @@ -446,6 +496,10 @@ msgid "Interface" msgstr "Interface" #: src/routes/settings.tsx:51 +msgid "Scrobbling" +msgstr "" + +#: src/routes/settings.tsx:54 msgid "About" msgstr "À propos" diff --git a/src/translations/ja.po b/src/translations/ja.po index 4445ee37a..988c8b99f 100644 --- a/src/translations/ja.po +++ b/src/translations/ja.po @@ -195,6 +195,7 @@ msgstr "トラックを削除" #: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 +#: src/routes/settings.scrobbling.tsx:115 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "キャンセル" @@ -437,6 +438,55 @@ msgstr "リセット" msgid "Reset library" msgstr "ライブラリをリセット" +#: src/routes/settings.scrobbling.tsx:37 +msgid "Failed to start Last.fm authentication. Please try again." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:52 +msgid "Failed to complete authentication. Make sure you authorized the app on Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:62 +msgid "Failed to disconnect from Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:76 +msgid "Last.fm Integration" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:78 +msgid "Connect your Last.fm account to scrobble tracks and share your listening history." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:88 +msgid "Connect to Last.fm" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:97 +msgid "A browser window has been opened. Please authorize Museeks on Last.fm, then click the button below to complete the connection." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:104 +msgid "I've authorized Museeks" +msgstr "" + +#. placeholder {0}: config.lastfm_username +#: src/routes/settings.scrobbling.tsx:126 +msgid "Connected as <0>{0}" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:136 +msgid "Disconnect" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:142 +msgid "Enable scrobbling" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:143 +msgid "Send your listening history to Last.fm" +msgstr "" + #: src/routes/settings.tsx:45 msgid "Audio" msgstr "オーディオ" @@ -446,6 +496,10 @@ msgid "Interface" msgstr "インターフェース" #: src/routes/settings.tsx:51 +msgid "Scrobbling" +msgstr "" + +#: src/routes/settings.tsx:54 msgid "About" msgstr "について" diff --git a/src/translations/ru.po b/src/translations/ru.po index c1b9e07ba..cd735b0e0 100644 --- a/src/translations/ru.po +++ b/src/translations/ru.po @@ -195,6 +195,7 @@ msgstr "Удалить треки" #: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 +#: src/routes/settings.scrobbling.tsx:115 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "Отмена" @@ -437,6 +438,55 @@ msgstr "Сбросить" msgid "Reset library" msgstr "Сбросить библиотеку" +#: src/routes/settings.scrobbling.tsx:37 +msgid "Failed to start Last.fm authentication. Please try again." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:52 +msgid "Failed to complete authentication. Make sure you authorized the app on Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:62 +msgid "Failed to disconnect from Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:76 +msgid "Last.fm Integration" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:78 +msgid "Connect your Last.fm account to scrobble tracks and share your listening history." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:88 +msgid "Connect to Last.fm" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:97 +msgid "A browser window has been opened. Please authorize Museeks on Last.fm, then click the button below to complete the connection." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:104 +msgid "I've authorized Museeks" +msgstr "" + +#. placeholder {0}: config.lastfm_username +#: src/routes/settings.scrobbling.tsx:126 +msgid "Connected as <0>{0}" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:136 +msgid "Disconnect" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:142 +msgid "Enable scrobbling" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:143 +msgid "Send your listening history to Last.fm" +msgstr "" + #: src/routes/settings.tsx:45 msgid "Audio" msgstr "Аудио" @@ -446,6 +496,10 @@ msgid "Interface" msgstr "Интерфейс" #: src/routes/settings.tsx:51 +msgid "Scrobbling" +msgstr "" + +#: src/routes/settings.tsx:54 msgid "About" msgstr "О программе" diff --git a/src/translations/zh-CN.po b/src/translations/zh-CN.po index 74ace214f..41419506f 100644 --- a/src/translations/zh-CN.po +++ b/src/translations/zh-CN.po @@ -195,6 +195,7 @@ msgstr "移除音轨" #: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 +#: src/routes/settings.scrobbling.tsx:115 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "取消" @@ -437,6 +438,55 @@ msgstr "重置" msgid "Reset library" msgstr "重置音乐库" +#: src/routes/settings.scrobbling.tsx:37 +msgid "Failed to start Last.fm authentication. Please try again." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:52 +msgid "Failed to complete authentication. Make sure you authorized the app on Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:62 +msgid "Failed to disconnect from Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:76 +msgid "Last.fm Integration" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:78 +msgid "Connect your Last.fm account to scrobble tracks and share your listening history." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:88 +msgid "Connect to Last.fm" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:97 +msgid "A browser window has been opened. Please authorize Museeks on Last.fm, then click the button below to complete the connection." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:104 +msgid "I've authorized Museeks" +msgstr "" + +#. placeholder {0}: config.lastfm_username +#: src/routes/settings.scrobbling.tsx:126 +msgid "Connected as <0>{0}" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:136 +msgid "Disconnect" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:142 +msgid "Enable scrobbling" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:143 +msgid "Send your listening history to Last.fm" +msgstr "" + #: src/routes/settings.tsx:45 msgid "Audio" msgstr "音频" @@ -446,6 +496,10 @@ msgid "Interface" msgstr "界面" #: src/routes/settings.tsx:51 +msgid "Scrobbling" +msgstr "" + +#: src/routes/settings.tsx:54 msgid "About" msgstr "关于" diff --git a/src/translations/zh-TW.po b/src/translations/zh-TW.po index 44a2e31b8..d34801b3d 100644 --- a/src/translations/zh-TW.po +++ b/src/translations/zh-TW.po @@ -195,6 +195,7 @@ msgstr "移除音軌" #: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 +#: src/routes/settings.scrobbling.tsx:115 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "取消" @@ -437,6 +438,55 @@ msgstr "重設" msgid "Reset library" msgstr "重設音樂庫" +#: src/routes/settings.scrobbling.tsx:37 +msgid "Failed to start Last.fm authentication. Please try again." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:52 +msgid "Failed to complete authentication. Make sure you authorized the app on Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:62 +msgid "Failed to disconnect from Last.fm." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:76 +msgid "Last.fm Integration" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:78 +msgid "Connect your Last.fm account to scrobble tracks and share your listening history." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:88 +msgid "Connect to Last.fm" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:97 +msgid "A browser window has been opened. Please authorize Museeks on Last.fm, then click the button below to complete the connection." +msgstr "" + +#: src/routes/settings.scrobbling.tsx:104 +msgid "I've authorized Museeks" +msgstr "" + +#. placeholder {0}: config.lastfm_username +#: src/routes/settings.scrobbling.tsx:126 +msgid "Connected as <0>{0}" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:136 +msgid "Disconnect" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:142 +msgid "Enable scrobbling" +msgstr "" + +#: src/routes/settings.scrobbling.tsx:143 +msgid "Send your listening history to Last.fm" +msgstr "" + #: src/routes/settings.tsx:45 msgid "Audio" msgstr "音訊" @@ -446,6 +496,10 @@ msgid "Interface" msgstr "介面" #: src/routes/settings.tsx:51 +msgid "Scrobbling" +msgstr "" + +#: src/routes/settings.tsx:54 msgid "About" msgstr "關於"