diff options
Diffstat (limited to '')
| -rw-r--r-- | Cargo.lock | 89 | ||||
| -rw-r--r-- | Cargo.toml | 6 | ||||
| -rw-r--r-- | TODO.md | 2 | ||||
| -rw-r--r-- | src/cli.rs | 8 | ||||
| -rw-r--r-- | src/daemon.rs | 325 | ||||
| -rw-r--r-- | src/queue.rs | 20 | ||||
| -rw-r--r-- | src/scrobbler.rs | 301 | ||||
| -rw-r--r-- | tests/queue.rs | 2 | ||||
| -rw-r--r-- | tests/scrobbler.rs | 48 |
9 files changed, 735 insertions, 66 deletions
| @@ -279,6 +279,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 279 | checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" | 279 | checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" |
| 280 | 280 | ||
| 281 | [[package]] | 281 | [[package]] |
| 282 | name = "block-buffer" | ||
| 283 | version = "0.12.0" | ||
| 284 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 285 | checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" | ||
| 286 | dependencies = [ | ||
| 287 | "hybrid-array", | ||
| 288 | ] | ||
| 289 | |||
| 290 | [[package]] | ||
| 282 | name = "block2" | 291 | name = "block2" |
| 283 | version = "0.6.2" | 292 | version = "0.6.2" |
| 284 | source = "registry+https://github.com/rust-lang/crates.io-index" | 293 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -456,6 +465,12 @@ dependencies = [ | |||
| 456 | ] | 465 | ] |
| 457 | 466 | ||
| 458 | [[package]] | 467 | [[package]] |
| 468 | name = "const-oid" | ||
| 469 | version = "0.10.2" | ||
| 470 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 471 | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | ||
| 472 | |||
| 473 | [[package]] | ||
| 459 | name = "core-foundation" | 474 | name = "core-foundation" |
| 460 | version = "0.9.4" | 475 | version = "0.9.4" |
| 461 | source = "registry+https://github.com/rust-lang/crates.io-index" | 476 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -541,6 +556,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 541 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | 556 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" |
| 542 | 557 | ||
| 543 | [[package]] | 558 | [[package]] |
| 559 | name = "crypto-common" | ||
| 560 | version = "0.2.1" | ||
| 561 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 562 | checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" | ||
| 563 | dependencies = [ | ||
| 564 | "hybrid-array", | ||
| 565 | ] | ||
| 566 | |||
| 567 | [[package]] | ||
| 544 | name = "ctrlc" | 568 | name = "ctrlc" |
| 545 | version = "3.5.2" | 569 | version = "3.5.2" |
| 546 | source = "registry+https://github.com/rust-lang/crates.io-index" | 570 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -567,6 +591,17 @@ dependencies = [ | |||
| 567 | ] | 591 | ] |
| 568 | 592 | ||
| 569 | [[package]] | 593 | [[package]] |
| 594 | name = "digest" | ||
| 595 | version = "0.11.3" | ||
| 596 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 597 | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" | ||
| 598 | dependencies = [ | ||
| 599 | "block-buffer", | ||
| 600 | "const-oid", | ||
| 601 | "crypto-common", | ||
| 602 | ] | ||
| 603 | |||
| 604 | [[package]] | ||
| 570 | name = "directories" | 605 | name = "directories" |
| 571 | version = "6.0.0" | 606 | version = "6.0.0" |
| 572 | source = "registry+https://github.com/rust-lang/crates.io-index" | 607 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -768,6 +803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 768 | checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" | 803 | checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" |
| 769 | dependencies = [ | 804 | dependencies = [ |
| 770 | "futures-core", | 805 | "futures-core", |
| 806 | "futures-sink", | ||
| 771 | ] | 807 | ] |
| 772 | 808 | ||
| 773 | [[package]] | 809 | [[package]] |
| @@ -814,7 +850,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 814 | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" | 850 | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" |
| 815 | dependencies = [ | 851 | dependencies = [ |
| 816 | "futures-core", | 852 | "futures-core", |
| 853 | "futures-io", | ||
| 854 | "futures-sink", | ||
| 817 | "futures-task", | 855 | "futures-task", |
| 856 | "memchr", | ||
| 818 | "pin-project-lite", | 857 | "pin-project-lite", |
| 819 | "slab", | 858 | "slab", |
| 820 | ] | 859 | ] |
| @@ -970,6 +1009,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 970 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | 1009 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" |
| 971 | 1010 | ||
| 972 | [[package]] | 1011 | [[package]] |
| 1012 | name = "hybrid-array" | ||
| 1013 | version = "0.4.12" | ||
| 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1015 | checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" | ||
| 1016 | dependencies = [ | ||
| 1017 | "typenum", | ||
| 1018 | ] | ||
| 1019 | |||
| 1020 | [[package]] | ||
| 973 | name = "hyper" | 1021 | name = "hyper" |
| 974 | version = "1.9.0" | 1022 | version = "1.9.0" |
| 975 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1023 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1382,6 +1430,16 @@ dependencies = [ | |||
| 1382 | ] | 1430 | ] |
| 1383 | 1431 | ||
| 1384 | [[package]] | 1432 | [[package]] |
| 1433 | name = "md-5" | ||
| 1434 | version = "0.11.0" | ||
| 1435 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1436 | checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" | ||
| 1437 | dependencies = [ | ||
| 1438 | "cfg-if", | ||
| 1439 | "digest", | ||
| 1440 | ] | ||
| 1441 | |||
| 1442 | [[package]] | ||
| 1385 | name = "memchr" | 1443 | name = "memchr" |
| 1386 | version = "2.8.0" | 1444 | version = "2.8.0" |
| 1387 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1445 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1924,7 +1982,9 @@ dependencies = [ | |||
| 1924 | "base64", | 1982 | "base64", |
| 1925 | "bytes", | 1983 | "bytes", |
| 1926 | "encoding_rs", | 1984 | "encoding_rs", |
| 1985 | "futures-channel", | ||
| 1927 | "futures-core", | 1986 | "futures-core", |
| 1987 | "futures-util", | ||
| 1928 | "h2", | 1988 | "h2", |
| 1929 | "http", | 1989 | "http", |
| 1930 | "http-body", | 1990 | "http-body", |
| @@ -1943,6 +2003,7 @@ dependencies = [ | |||
| 1943 | "rustls-platform-verifier", | 2003 | "rustls-platform-verifier", |
| 1944 | "serde", | 2004 | "serde", |
| 1945 | "serde_json", | 2005 | "serde_json", |
| 2006 | "serde_urlencoded", | ||
| 1946 | "sync_wrapper", | 2007 | "sync_wrapper", |
| 1947 | "tokio", | 2008 | "tokio", |
| 1948 | "tokio-rustls", | 2009 | "tokio-rustls", |
| @@ -1971,7 +2032,7 @@ dependencies = [ | |||
| 1971 | 2032 | ||
| 1972 | [[package]] | 2033 | [[package]] |
| 1973 | name = "rsp" | 2034 | name = "rsp" |
| 1974 | version = "0.5.0" | 2035 | version = "0.8.0" |
| 1975 | dependencies = [ | 2036 | dependencies = [ |
| 1976 | "chrono", | 2037 | "chrono", |
| 1977 | "clap", | 2038 | "clap", |
| @@ -1980,7 +2041,9 @@ dependencies = [ | |||
| 1980 | "ctrlc", | 2041 | "ctrlc", |
| 1981 | "directories", | 2042 | "directories", |
| 1982 | "dirs", | 2043 | "dirs", |
| 2044 | "hex", | ||
| 1983 | "libc", | 2045 | "libc", |
| 2046 | "md-5", | ||
| 1984 | "notify-rust", | 2047 | "notify-rust", |
| 1985 | "rand 0.10.1", | 2048 | "rand 0.10.1", |
| 1986 | "reqwest", | 2049 | "reqwest", |
| @@ -2129,6 +2192,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2129 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" | 2192 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" |
| 2130 | 2193 | ||
| 2131 | [[package]] | 2194 | [[package]] |
| 2195 | name = "ryu" | ||
| 2196 | version = "1.0.23" | ||
| 2197 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2198 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | ||
| 2199 | |||
| 2200 | [[package]] | ||
| 2132 | name = "same-file" | 2201 | name = "same-file" |
| 2133 | version = "1.0.6" | 2202 | version = "1.0.6" |
| 2134 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2203 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2245,6 +2314,18 @@ dependencies = [ | |||
| 2245 | ] | 2314 | ] |
| 2246 | 2315 | ||
| 2247 | [[package]] | 2316 | [[package]] |
| 2317 | name = "serde_urlencoded" | ||
| 2318 | version = "0.7.1" | ||
| 2319 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2320 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | ||
| 2321 | dependencies = [ | ||
| 2322 | "form_urlencoded", | ||
| 2323 | "itoa", | ||
| 2324 | "ryu", | ||
| 2325 | "serde", | ||
| 2326 | ] | ||
| 2327 | |||
| 2328 | [[package]] | ||
| 2248 | name = "shlex" | 2329 | name = "shlex" |
| 2249 | version = "1.3.0" | 2330 | version = "1.3.0" |
| 2250 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2331 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2879,6 +2960,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2879 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | 2960 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" |
| 2880 | 2961 | ||
| 2881 | [[package]] | 2962 | [[package]] |
| 2963 | name = "typenum" | ||
| 2964 | version = "1.20.0" | ||
| 2965 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2966 | checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" | ||
| 2967 | |||
| 2968 | [[package]] | ||
| 2882 | name = "uds_windows" | 2969 | name = "uds_windows" |
| 2883 | version = "1.2.1" | 2970 | version = "1.2.1" |
| 2884 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2971 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "rsp" | 2 | name = "rsp" |
| 3 | version = "0.5.0" | 3 | version = "0.8.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | description = "Rust music player daemon with Unix socket IPC" | 5 | description = "Rust music player daemon with Unix socket IPC" |
| 6 | license = "GPL-3.0 or later" | 6 | license = "GPL-3.0 or later" |
| @@ -23,7 +23,7 @@ directories = "6.0" | |||
| 23 | serde = { version = "1.0.228", features = ["derive"] } | 23 | serde = { version = "1.0.228", features = ["derive"] } |
| 24 | toml = "1.1.2" | 24 | toml = "1.1.2" |
| 25 | tokio = { version = "1.52.2", features = ["full"] } | 25 | tokio = { version = "1.52.2", features = ["full"] } |
| 26 | reqwest = { version = "0.13.3", features = ["json"] } | 26 | reqwest = { version = "0.13.3", features = ["json", "form", "blocking"] } |
| 27 | symphonia = { version = "0.5.5", features = ["all"] } | 27 | symphonia = { version = "0.5.5", features = ["all"] } |
| 28 | cpal = "0.17.3" | 28 | cpal = "0.17.3" |
| 29 | chrono = "0.4.44" | 29 | chrono = "0.4.44" |
| @@ -34,6 +34,8 @@ ctrlc = "3.5.2" | |||
| 34 | rand = "0.10.1" | 34 | rand = "0.10.1" |
| 35 | notify-rust = "4.17.0" | 35 | notify-rust = "4.17.0" |
| 36 | terminal_size = "0.4.4" | 36 | terminal_size = "0.4.4" |
| 37 | md-5 = "0.11.0" | ||
| 38 | hex = "0.4.3" | ||
| 37 | 39 | ||
| 38 | [dev-dependencies] | 40 | [dev-dependencies] |
| 39 | tempfile = "3.17" | 41 | tempfile = "3.17" |
| @@ -75,7 +75,7 @@ | |||
| 75 | 75 | ||
| 76 | - [ ] **Persist queue state** - Save queue to disk so it survives daemon restarts. | 76 | - [ ] **Persist queue state** - Save queue to disk so it survives daemon restarts. |
| 77 | 77 | ||
| 78 | - [ ] **Scrobbling (Last.fm/ListenBrainz)** - Add scrobbling support via configured credentials. | 78 | - [x] **Scrobbling (Last.fm/ListenBrainz)** - Full implementation with Last.fm API signing, ListenBrainz token auth, daemon integration, token validation, and `rsp settings reload`. |
| 79 | 79 | ||
| 80 | - [ ] **Dynamic autocompletion of artists, albums and tracks** - Let shell communicate with the daemon so it returns autocompletions for shell | 80 | - [ ] **Dynamic autocompletion of artists, albums and tracks** - Let shell communicate with the daemon so it returns autocompletions for shell |
| 81 | 81 | ||
| @@ -105,6 +105,8 @@ pub enum SettingsCommands { | |||
| 105 | #[arg(value_name = "KEY")] | 105 | #[arg(value_name = "KEY")] |
| 106 | key: String, | 106 | key: String, |
| 107 | }, | 107 | }, |
| 108 | /// Reload config from disk (re-reads config file, re-validates scrobbling tokens) | ||
| 109 | Reload, | ||
| 108 | } | 110 | } |
| 109 | 111 | ||
| 110 | impl Cli { | 112 | impl Cli { |
| @@ -199,6 +201,9 @@ impl Cli { | |||
| 199 | SettingsCommands::Set { key, value } => { | 201 | SettingsCommands::Set { key, value } => { |
| 200 | config.set(key, value)?; | 202 | config.set(key, value)?; |
| 201 | config.save()?; | 203 | config.save()?; |
| 204 | if key == "listenbrainz_token" { | ||
| 205 | let _ = crate::daemon::send_command("validate".to_string()); | ||
| 206 | } | ||
| 202 | } | 207 | } |
| 203 | SettingsCommands::List => { | 208 | SettingsCommands::List => { |
| 204 | config.list(); | 209 | config.list(); |
| @@ -207,6 +212,9 @@ impl Cli { | |||
| 207 | config.unset(key)?; | 212 | config.unset(key)?; |
| 208 | config.save()?; | 213 | config.save()?; |
| 209 | } | 214 | } |
| 215 | SettingsCommands::Reload => { | ||
| 216 | let _ = crate::daemon::send_command("reload".to_string()); | ||
| 217 | } | ||
| 210 | } | 218 | } |
| 211 | } | 219 | } |
| 212 | Commands::Completions { shell } => { | 220 | Commands::Completions { shell } => { |
diff --git a/src/daemon.rs b/src/daemon.rs index 2b38025..06b925e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs | |||
| @@ -2,13 +2,192 @@ use crate::audio::AudioEngine; | |||
| 2 | use crate::config::Config; | 2 | use crate::config::Config; |
| 3 | use crate::notification; | 3 | use crate::notification; |
| 4 | use crate::queue::{Queue, ShuffleMode, Track}; | 4 | use crate::queue::{Queue, ShuffleMode, Track}; |
| 5 | use crate::scrobbler::Scrobbler; | ||
| 5 | use std::io::{Read, Write}; | 6 | use std::io::{Read, Write}; |
| 6 | use std::os::unix::net::{UnixListener, UnixStream}; | 7 | use std::os::unix::net::{UnixListener, UnixStream}; |
| 7 | use std::path::{Path, PathBuf}; | 8 | use std::path::{Path, PathBuf}; |
| 8 | use std::sync::atomic::{AtomicBool, Ordering}; | 9 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 9 | use std::sync::{Arc, Mutex}; | 10 | use std::sync::{Arc, Mutex, RwLock}; |
| 10 | use std::thread; | 11 | use std::thread; |
| 11 | use std::time::Duration; | 12 | use std::time::{Duration, Instant, SystemTime}; |
| 13 | |||
| 14 | struct ScrobbleState { | ||
| 15 | track: Option<Track>, | ||
| 16 | start_time: Option<Instant>, | ||
| 17 | timestamp: Option<u64>, | ||
| 18 | } | ||
| 19 | |||
| 20 | impl ScrobbleState { | ||
| 21 | fn new() -> Self { | ||
| 22 | ScrobbleState { | ||
| 23 | track: None, | ||
| 24 | start_time: None, | ||
| 25 | timestamp: None, | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | fn start(&mut self, track: Track) { | ||
| 30 | let ts = SystemTime::now() | ||
| 31 | .duration_since(SystemTime::UNIX_EPOCH) | ||
| 32 | .unwrap_or_default() | ||
| 33 | .as_secs(); | ||
| 34 | let title = track.title.clone().unwrap_or_default(); | ||
| 35 | let artist = track.artist.clone().unwrap_or_default(); | ||
| 36 | eprintln!( | ||
| 37 | "scrobble: tracking start for '{} - {}' (ts={})", | ||
| 38 | artist, title, ts | ||
| 39 | ); | ||
| 40 | self.track = Some(track); | ||
| 41 | self.start_time = Some(Instant::now()); | ||
| 42 | self.timestamp = Some(ts); | ||
| 43 | } | ||
| 44 | |||
| 45 | fn should_scrobble(&self) -> bool { | ||
| 46 | let (Some(track), Some(start)) = (&self.track, self.start_time) else { | ||
| 47 | eprintln!("scrobble: should_scrobble false — no track or start_time"); | ||
| 48 | return false; | ||
| 49 | }; | ||
| 50 | let elapsed = start.elapsed().as_secs(); | ||
| 51 | let result = Self::should_scrobble_with_params(elapsed, track.duration_secs); | ||
| 52 | let threshold = track | ||
| 53 | .duration_secs | ||
| 54 | .map(|d| (d / 2.0) as u64) | ||
| 55 | .unwrap_or(u64::MAX) | ||
| 56 | .min(240); | ||
| 57 | eprintln!( | ||
| 58 | "scrobble: should_scrobble {} (elapsed={}s threshold={}s duration={:?})", | ||
| 59 | result, elapsed, threshold, track.duration_secs | ||
| 60 | ); | ||
| 61 | result | ||
| 62 | } | ||
| 63 | |||
| 64 | fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool { | ||
| 65 | if elapsed < 30 { | ||
| 66 | return false; | ||
| 67 | } | ||
| 68 | let threshold = duration_secs | ||
| 69 | .map(|d| (d / 2.0) as u64) | ||
| 70 | .unwrap_or(u64::MAX) | ||
| 71 | .min(240); | ||
| 72 | elapsed >= threshold | ||
| 73 | } | ||
| 74 | |||
| 75 | fn clear(&mut self) { | ||
| 76 | self.track = None; | ||
| 77 | self.start_time = None; | ||
| 78 | self.timestamp = None; | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | #[cfg(test)] | ||
| 83 | mod tests { | ||
| 84 | use super::*; | ||
| 85 | |||
| 86 | #[test] | ||
| 87 | fn test_scrobble_state_new() { | ||
| 88 | let s = ScrobbleState::new(); | ||
| 89 | assert!(s.track.is_none()); | ||
| 90 | assert!(s.start_time.is_none()); | ||
| 91 | assert!(s.timestamp.is_none()); | ||
| 92 | } | ||
| 93 | |||
| 94 | #[test] | ||
| 95 | fn test_scrobble_state_clear() { | ||
| 96 | let mut s = ScrobbleState::new(); | ||
| 97 | let t = Track { | ||
| 98 | path: PathBuf::from("/test/song.mp3"), | ||
| 99 | title: Some("Test".into()), | ||
| 100 | artist: Some("Artist".into()), | ||
| 101 | album: None, | ||
| 102 | duration_secs: Some(200.0), | ||
| 103 | }; | ||
| 104 | s.start(t); | ||
| 105 | assert!(s.track.is_some()); | ||
| 106 | s.clear(); | ||
| 107 | assert!(s.track.is_none()); | ||
| 108 | assert!(s.start_time.is_none()); | ||
| 109 | assert!(s.timestamp.is_none()); | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 113 | fn test_should_scrobble_without_track_returns_false() { | ||
| 114 | assert!(!ScrobbleState::new().should_scrobble()); | ||
| 115 | } | ||
| 116 | |||
| 117 | #[test] | ||
| 118 | fn test_should_scrobble_threshold_below_30_seconds() { | ||
| 119 | assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0))); | ||
| 120 | } | ||
| 121 | |||
| 122 | #[test] | ||
| 123 | fn test_should_scrobble_threshold_half_duration() { | ||
| 124 | assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0))); | ||
| 125 | } | ||
| 126 | |||
| 127 | #[test] | ||
| 128 | fn test_should_scrobble_threshold_below_half_duration() { | ||
| 129 | assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0))); | ||
| 130 | } | ||
| 131 | |||
| 132 | #[test] | ||
| 133 | fn test_should_scrobble_threshold_capped_at_4min() { | ||
| 134 | assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0))); | ||
| 135 | assert!(!ScrobbleState::should_scrobble_with_params( | ||
| 136 | 239, | ||
| 137 | Some(600.0) | ||
| 138 | )); | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn test_should_scrobble_no_duration_requires_4min() { | ||
| 143 | assert!(ScrobbleState::should_scrobble_with_params(240, None)); | ||
| 144 | assert!(!ScrobbleState::should_scrobble_with_params(239, None)); | ||
| 145 | } | ||
| 146 | |||
| 147 | #[test] | ||
| 148 | fn test_should_scrobble_short_song() { | ||
| 149 | assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0))); | ||
| 150 | assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0))); | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 154 | fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { | ||
| 155 | if state.should_scrobble() | ||
| 156 | && let (Some(track), Some(ts)) = (&state.track, state.timestamp) | ||
| 157 | { | ||
| 158 | let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); | ||
| 159 | let title = track.title.as_deref().unwrap_or("(unknown title)"); | ||
| 160 | let album = track.album.as_deref(); | ||
| 161 | eprintln!( | ||
| 162 | "scrobble: sending scrobble for '{} - {}' at ts={}", | ||
| 163 | artist, title, ts | ||
| 164 | ); | ||
| 165 | if let Ok(s) = scrobbler.read() { | ||
| 166 | s.scrobble(artist, title, album, ts); | ||
| 167 | } | ||
| 168 | } else { | ||
| 169 | let title = state | ||
| 170 | .track | ||
| 171 | .as_ref() | ||
| 172 | .and_then(|t| t.title.as_deref()) | ||
| 173 | .unwrap_or("(no track)"); | ||
| 174 | eprintln!( | ||
| 175 | "scrobble: skipping scrobble for '{}' — not enough play time", | ||
| 176 | title | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | state.clear(); | ||
| 180 | } | ||
| 181 | |||
| 182 | fn send_now_playing(scrobbler: &RwLock<Scrobbler>, track: &Track) { | ||
| 183 | let artist = track.artist.as_deref().unwrap_or("(unknown artist)"); | ||
| 184 | let title = track.title.as_deref().unwrap_or("(unknown title)"); | ||
| 185 | let album = track.album.as_deref(); | ||
| 186 | eprintln!("scrobble: sending now_playing for '{} - {}'", artist, title); | ||
| 187 | if let Ok(s) = scrobbler.read() { | ||
| 188 | s.now_playing(artist, title, album); | ||
| 189 | } | ||
| 190 | } | ||
| 12 | 191 | ||
| 13 | pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | 192 | pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { |
| 14 | let config = Config::load()?; | 193 | let config = Config::load()?; |
| @@ -68,6 +247,26 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 68 | } | 247 | } |
| 69 | } | 248 | } |
| 70 | 249 | ||
| 250 | // Create scrobbler from config | ||
| 251 | let scrobbler = Arc::new(RwLock::new(Scrobbler::new( | ||
| 252 | config.lastfm_api_key.clone(), | ||
| 253 | config.lastfm_api_secret.clone(), | ||
| 254 | config.lastfm_session_key.clone(), | ||
| 255 | config.listenbrainz_token.clone(), | ||
| 256 | ))); | ||
| 257 | // Validate ListenBrainz token at startup | ||
| 258 | if let Some(token) = &config.listenbrainz_token | ||
| 259 | && !token.is_empty() | ||
| 260 | && let Ok(s) = scrobbler.read() | ||
| 261 | { | ||
| 262 | match s.validate_listenbrainz_token() { | ||
| 263 | Ok(Some(user)) => eprintln!("scrobble: listenbrainz token valid (user: {})", user), | ||
| 264 | Ok(None) => eprintln!("scrobble: listenbrainz token invalid, scrobbling disabled"), | ||
| 265 | Err(e) => eprintln!("scrobble: listenbrainz validation error: {}", e), | ||
| 266 | } | ||
| 267 | } | ||
| 268 | let scrobble_state = Arc::new(Mutex::new(ScrobbleState::new())); | ||
| 269 | |||
| 71 | // Save full config | 270 | // Save full config |
| 72 | config.save()?; | 271 | config.save()?; |
| 73 | 272 | ||
| @@ -98,6 +297,10 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 98 | if audio_engine.poll_track_ended() | 297 | if audio_engine.poll_track_ended() |
| 99 | && let Ok(mut q) = queue.lock() | 298 | && let Ok(mut q) = queue.lock() |
| 100 | { | 299 | { |
| 300 | // Scrobble the track that just ended | ||
| 301 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 302 | try_scrobble_current(&scrobbler, &mut s); | ||
| 303 | } | ||
| 101 | let original_position = q.position(); | 304 | let original_position = q.position(); |
| 102 | let mut started = false; | 305 | let mut started = false; |
| 103 | let mut had_next = false; | 306 | let mut had_next = false; |
| @@ -108,8 +311,14 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 108 | audio_engine.play(next_track.clone()); | 311 | audio_engine.play(next_track.clone()); |
| 109 | if wait_for_start(&audio_engine, &next_track, 500) { | 312 | if wait_for_start(&audio_engine, &next_track, 500) { |
| 110 | started = true; | 313 | started = true; |
| 314 | let track = Track::from_path(next_track.clone()); | ||
| 315 | // Send now playing and record scrobble start | ||
| 316 | send_now_playing(&scrobbler, &track); | ||
| 317 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 318 | s.start(track.clone()); | ||
| 319 | } | ||
| 111 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); | 320 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); |
| 112 | notification::notify_track(&Track::from_path(next_track), fmt.as_deref()); | 321 | notification::notify_track(&track, fmt.as_deref()); |
| 113 | break; | 322 | break; |
| 114 | } | 323 | } |
| 115 | } else { | 324 | } else { |
| @@ -132,8 +341,17 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 132 | let audio_clone = Arc::clone(&audio_engine); | 341 | let audio_clone = Arc::clone(&audio_engine); |
| 133 | let shutdown_flag_clone = Arc::clone(&shutdown_flag); | 342 | let shutdown_flag_clone = Arc::clone(&shutdown_flag); |
| 134 | let queue_clone = Arc::clone(&queue); | 343 | let queue_clone = Arc::clone(&queue); |
| 344 | let scrobbler_clone = Arc::clone(&scrobbler); | ||
| 345 | let scrobble_state_clone = Arc::clone(&scrobble_state); | ||
| 135 | thread::spawn(move || { | 346 | thread::spawn(move || { |
| 136 | handle_client(stream, audio_clone, shutdown_flag_clone, queue_clone); | 347 | handle_client( |
| 348 | stream, | ||
| 349 | audio_clone, | ||
| 350 | shutdown_flag_clone, | ||
| 351 | queue_clone, | ||
| 352 | scrobbler_clone, | ||
| 353 | scrobble_state_clone, | ||
| 354 | ); | ||
| 137 | }); | 355 | }); |
| 138 | } | 356 | } |
| 139 | Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { | 357 | Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { |
| @@ -236,11 +454,20 @@ fn handle_client( | |||
| 236 | audio: Arc<AudioEngine>, | 454 | audio: Arc<AudioEngine>, |
| 237 | shutdown_flag: Arc<AtomicBool>, | 455 | shutdown_flag: Arc<AtomicBool>, |
| 238 | queue: Arc<Mutex<Queue>>, | 456 | queue: Arc<Mutex<Queue>>, |
| 457 | scrobbler: Arc<RwLock<Scrobbler>>, | ||
| 458 | scrobble_state: Arc<Mutex<ScrobbleState>>, | ||
| 239 | ) { | 459 | ) { |
| 240 | let mut buffer = [0; 1024]; | 460 | let mut buffer = [0; 1024]; |
| 241 | if let Ok(size) = stream.read(&mut buffer) { | 461 | if let Ok(size) = stream.read(&mut buffer) { |
| 242 | let cmd = String::from_utf8_lossy(&buffer[..size]); | 462 | let cmd = String::from_utf8_lossy(&buffer[..size]); |
| 243 | let response = process_command(cmd.trim(), &audio, &shutdown_flag, &queue); | 463 | let response = process_command( |
| 464 | cmd.trim(), | ||
| 465 | &audio, | ||
| 466 | &shutdown_flag, | ||
| 467 | &queue, | ||
| 468 | &scrobbler, | ||
| 469 | &scrobble_state, | ||
| 470 | ); | ||
| 244 | let _ = stream.write_all(response.as_bytes()); | 471 | let _ = stream.write_all(response.as_bytes()); |
| 245 | } | 472 | } |
| 246 | } | 473 | } |
| @@ -250,6 +477,8 @@ fn process_command( | |||
| 250 | audio: &AudioEngine, | 477 | audio: &AudioEngine, |
| 251 | shutdown_flag: &Arc<AtomicBool>, | 478 | shutdown_flag: &Arc<AtomicBool>, |
| 252 | queue: &Arc<Mutex<Queue>>, | 479 | queue: &Arc<Mutex<Queue>>, |
| 480 | scrobbler: &RwLock<Scrobbler>, | ||
| 481 | scrobble_state: &Arc<Mutex<ScrobbleState>>, | ||
| 253 | ) -> String { | 482 | ) -> String { |
| 254 | match cmd { | 483 | match cmd { |
| 255 | "status" => { | 484 | "status" => { |
| @@ -291,6 +520,9 @@ fn process_command( | |||
| 291 | } | 520 | } |
| 292 | } | 521 | } |
| 293 | "stop" => { | 522 | "stop" => { |
| 523 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 524 | try_scrobble_current(scrobbler, &mut s); | ||
| 525 | } | ||
| 294 | audio.stop(); | 526 | audio.stop(); |
| 295 | if let Ok(mut q) = queue.lock() { | 527 | if let Ok(mut q) = queue.lock() { |
| 296 | q.clear(); | 528 | q.clear(); |
| @@ -299,19 +531,57 @@ fn process_command( | |||
| 299 | "Stopped\n".to_string() | 531 | "Stopped\n".to_string() |
| 300 | } | 532 | } |
| 301 | "stop_daemon" => { | 533 | "stop_daemon" => { |
| 302 | // Signal the daemon to shut down | ||
| 303 | shutdown_flag.store(true, Ordering::SeqCst); | 534 | shutdown_flag.store(true, Ordering::SeqCst); |
| 304 | "Shutting down\n".to_string() | 535 | "Shutting down\n".to_string() |
| 305 | } | 536 | } |
| 537 | "reload" | "validate" => { | ||
| 538 | if let Ok(config) = Config::load() { | ||
| 539 | let temp_scrobbler = Scrobbler::new( | ||
| 540 | config.lastfm_api_key.clone(), | ||
| 541 | config.lastfm_api_secret.clone(), | ||
| 542 | config.lastfm_session_key.clone(), | ||
| 543 | config.listenbrainz_token.clone(), | ||
| 544 | ); | ||
| 545 | let result = match temp_scrobbler.validate_listenbrainz_token() { | ||
| 546 | Ok(Some(user)) => { | ||
| 547 | notification::notify_change(&format!( | ||
| 548 | "Logged in to ListenBrainz as {}", | ||
| 549 | user | ||
| 550 | )); | ||
| 551 | format!("ListenBrainz token valid (user: {})\n", user) | ||
| 552 | } | ||
| 553 | Ok(None) => { | ||
| 554 | notification::notify_error("Invalid ListenBrainz token"); | ||
| 555 | "ListenBrainz token invalid\n".to_string() | ||
| 556 | } | ||
| 557 | Err(e) => { | ||
| 558 | notification::notify_error(&format!("ListenBrainz validation: {}", e)); | ||
| 559 | format!("ListenBrainz validation error: {}\n", e) | ||
| 560 | } | ||
| 561 | }; | ||
| 562 | // Replace shared scrobbler state (only on reload, both is fine) | ||
| 563 | if let Ok(mut s) = scrobbler.write() { | ||
| 564 | *s = temp_scrobbler; | ||
| 565 | } | ||
| 566 | result | ||
| 567 | } else { | ||
| 568 | "Failed to load config\n".to_string() | ||
| 569 | } | ||
| 570 | } | ||
| 306 | "next" => { | 571 | "next" => { |
| 572 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 573 | try_scrobble_current(scrobbler, &mut s); | ||
| 574 | } | ||
| 307 | if let Ok(mut q) = queue.lock() { | 575 | if let Ok(mut q) = queue.lock() { |
| 308 | if let Some(next_track) = q.next() { | 576 | if let Some(next_track) = q.next() { |
| 309 | audio.play(next_track.clone()); | 577 | audio.play(next_track.clone()); |
| 578 | let track = Track::from_path(next_track.clone()); | ||
| 579 | send_now_playing(scrobbler, &track); | ||
| 580 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 581 | s.start(track.clone()); | ||
| 582 | } | ||
| 310 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); | 583 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); |
| 311 | notification::notify_track( | 584 | notification::notify_track(&track, fmt.as_deref()); |
| 312 | &Track::from_path(next_track.clone()), | ||
| 313 | fmt.as_deref(), | ||
| 314 | ); | ||
| 315 | "Playing next track\n".to_string() | 585 | "Playing next track\n".to_string() |
| 316 | } else { | 586 | } else { |
| 317 | audio.stop(); | 587 | audio.stop(); |
| @@ -323,14 +593,19 @@ fn process_command( | |||
| 323 | } | 593 | } |
| 324 | } | 594 | } |
| 325 | "previous" => { | 595 | "previous" => { |
| 596 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 597 | try_scrobble_current(scrobbler, &mut s); | ||
| 598 | } | ||
| 326 | if let Ok(mut q) = queue.lock() { | 599 | if let Ok(mut q) = queue.lock() { |
| 327 | if let Some(prev_track) = q.previous() { | 600 | if let Some(prev_track) = q.previous() { |
| 328 | audio.play(prev_track.clone()); | 601 | audio.play(prev_track.clone()); |
| 602 | let track = Track::from_path(prev_track.clone()); | ||
| 603 | send_now_playing(scrobbler, &track); | ||
| 604 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 605 | s.start(track.clone()); | ||
| 606 | } | ||
| 329 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); | 607 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); |
| 330 | notification::notify_track( | 608 | notification::notify_track(&track, fmt.as_deref()); |
| 331 | &Track::from_path(prev_track.clone()), | ||
| 332 | fmt.as_deref(), | ||
| 333 | ); | ||
| 334 | "Playing previous track\n".to_string() | 609 | "Playing previous track\n".to_string() |
| 335 | } else { | 610 | } else { |
| 336 | "No previous track\n".to_string() | 611 | "No previous track\n".to_string() |
| @@ -408,6 +683,9 @@ fn process_command( | |||
| 408 | let was_empty = q.is_empty(); | 683 | let was_empty = q.is_empty(); |
| 409 | match position { | 684 | match position { |
| 410 | "now" => { | 685 | "now" => { |
| 686 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 687 | try_scrobble_current(scrobbler, &mut s); | ||
| 688 | } | ||
| 411 | q.insert_after_current(path.clone()); | 689 | q.insert_after_current(path.clone()); |
| 412 | if !was_empty { | 690 | if !was_empty { |
| 413 | let new_pos = q.position() + 1; | 691 | let new_pos = q.position() + 1; |
| @@ -415,6 +693,10 @@ fn process_command( | |||
| 415 | } | 693 | } |
| 416 | if let Some(track) = q.track_at(q.position()) { | 694 | if let Some(track) = q.track_at(q.position()) { |
| 417 | audio.play(track.path.clone()); | 695 | audio.play(track.path.clone()); |
| 696 | send_now_playing(scrobbler, track); | ||
| 697 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 698 | s.start(track.clone()); | ||
| 699 | } | ||
| 418 | let fmt = | 700 | let fmt = |
| 419 | Config::load().ok().and_then(|c| c.display_format.clone()); | 701 | Config::load().ok().and_then(|c| c.display_format.clone()); |
| 420 | notification::notify_track(track, fmt.as_deref()); | 702 | notification::notify_track(track, fmt.as_deref()); |
| @@ -432,6 +714,10 @@ fn process_command( | |||
| 432 | if was_empty && !was_playing { | 714 | if was_empty && !was_playing { |
| 433 | let track = Track::from_path(path.clone()); | 715 | let track = Track::from_path(path.clone()); |
| 434 | audio.play(path); | 716 | audio.play(path); |
| 717 | send_now_playing(scrobbler, &track); | ||
| 718 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 719 | s.start(track.clone()); | ||
| 720 | } | ||
| 435 | let fmt = | 721 | let fmt = |
| 436 | Config::load().ok().and_then(|c| c.display_format.clone()); | 722 | Config::load().ok().and_then(|c| c.display_format.clone()); |
| 437 | notification::notify_track(&track, fmt.as_deref()); | 723 | notification::notify_track(&track, fmt.as_deref()); |
| @@ -451,6 +737,9 @@ fn process_command( | |||
| 451 | let was_empty = q.is_empty(); | 737 | let was_empty = q.is_empty(); |
| 452 | match position { | 738 | match position { |
| 453 | "now" => { | 739 | "now" => { |
| 740 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 741 | try_scrobble_current(scrobbler, &mut s); | ||
| 742 | } | ||
| 454 | for file in files.iter().rev() { | 743 | for file in files.iter().rev() { |
| 455 | q.insert_after_current(file.clone()); | 744 | q.insert_after_current(file.clone()); |
| 456 | } | 745 | } |
| @@ -460,6 +749,10 @@ fn process_command( | |||
| 460 | } | 749 | } |
| 461 | if let Some(track) = q.track_at(q.position()) { | 750 | if let Some(track) = q.track_at(q.position()) { |
| 462 | audio.play(track.path.clone()); | 751 | audio.play(track.path.clone()); |
| 752 | send_now_playing(scrobbler, track); | ||
| 753 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 754 | s.start(track.clone()); | ||
| 755 | } | ||
| 463 | let fmt = Config::load() | 756 | let fmt = Config::load() |
| 464 | .ok() | 757 | .ok() |
| 465 | .and_then(|c| c.display_format.clone()); | 758 | .and_then(|c| c.display_format.clone()); |
| @@ -491,6 +784,10 @@ fn process_command( | |||
| 491 | && let Some(track) = q.current() | 784 | && let Some(track) = q.current() |
| 492 | { | 785 | { |
| 493 | audio.play(track.path.clone()); | 786 | audio.play(track.path.clone()); |
| 787 | send_now_playing(scrobbler, track); | ||
| 788 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 789 | s.start(track.clone()); | ||
| 790 | } | ||
| 494 | let fmt = Config::load() | 791 | let fmt = Config::load() |
| 495 | .ok() | 792 | .ok() |
| 496 | .and_then(|c| c.display_format.clone()); | 793 | .and_then(|c| c.display_format.clone()); |
diff --git a/src/queue.rs b/src/queue.rs index d42203f..8f98173 100644 --- a/src/queue.rs +++ b/src/queue.rs | |||
| @@ -13,6 +13,7 @@ pub struct AudioMetadata { | |||
| 13 | pub title: Option<String>, | 13 | pub title: Option<String>, |
| 14 | pub artist: Option<String>, | 14 | pub artist: Option<String>, |
| 15 | pub album: Option<String>, | 15 | pub album: Option<String>, |
| 16 | pub duration_secs: Option<f64>, | ||
| 16 | } | 17 | } |
| 17 | 18 | ||
| 18 | pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { | 19 | pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { |
| @@ -28,7 +29,22 @@ pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { | |||
| 28 | .format(&hint, mss, &Default::default(), &Default::default()) | 29 | .format(&hint, mss, &Default::default(), &Default::default()) |
| 29 | .ok()?; | 30 | .ok()?; |
| 30 | 31 | ||
| 31 | let mut metadata = AudioMetadata::default(); | 32 | // Extract duration from format track's codec parameters |
| 33 | let duration_secs = probed | ||
| 34 | .format | ||
| 35 | .tracks() | ||
| 36 | .iter() | ||
| 37 | .find(|t| t.codec_params.n_frames.is_some() && t.codec_params.time_base.is_some()) | ||
| 38 | .and_then(|t| { | ||
| 39 | let n_frames = t.codec_params.n_frames?; | ||
| 40 | let tb = t.codec_params.time_base?; | ||
| 41 | Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64) | ||
| 42 | }); | ||
| 43 | |||
| 44 | let mut metadata = AudioMetadata { | ||
| 45 | duration_secs, | ||
| 46 | ..Default::default() | ||
| 47 | }; | ||
| 32 | 48 | ||
| 33 | if let Some(meta) = probed.metadata.get() | 49 | if let Some(meta) = probed.metadata.get() |
| 34 | && let Some(rev) = meta.current() | 50 | && let Some(rev) = meta.current() |
| @@ -82,6 +98,7 @@ pub struct Track { | |||
| 82 | pub title: Option<String>, | 98 | pub title: Option<String>, |
| 83 | pub artist: Option<String>, | 99 | pub artist: Option<String>, |
| 84 | pub album: Option<String>, | 100 | pub album: Option<String>, |
| 101 | pub duration_secs: Option<f64>, | ||
| 85 | } | 102 | } |
| 86 | 103 | ||
| 87 | impl Track { | 104 | impl Track { |
| @@ -93,6 +110,7 @@ impl Track { | |||
| 93 | title: metadata.and_then(|m| m.title.clone()), | 110 | title: metadata.and_then(|m| m.title.clone()), |
| 94 | artist: metadata.and_then(|m| m.artist.clone()), | 111 | artist: metadata.and_then(|m| m.artist.clone()), |
| 95 | album: metadata.and_then(|m| m.album.clone()), | 112 | album: metadata.and_then(|m| m.album.clone()), |
| 113 | duration_secs: metadata.and_then(|m| m.duration_secs), | ||
| 96 | } | 114 | } |
| 97 | } | 115 | } |
| 98 | 116 | ||
diff --git a/src/scrobbler.rs b/src/scrobbler.rs index ddcbed9..2e79933 100644 --- a/src/scrobbler.rs +++ b/src/scrobbler.rs | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | use reqwest::Client; | 1 | use md5::{Digest, Md5}; |
| 2 | use reqwest::blocking::Client; | ||
| 3 | use std::collections::BTreeMap; | ||
| 2 | 4 | ||
| 3 | #[allow(dead_code)] | ||
| 4 | pub struct Scrobbler { | 5 | pub struct Scrobbler { |
| 5 | pub lastfm_api_key: Option<String>, | 6 | pub lastfm_api_key: Option<String>, |
| 6 | pub lastfm_api_secret: Option<String>, | 7 | pub lastfm_api_secret: Option<String>, |
| @@ -9,7 +10,10 @@ pub struct Scrobbler { | |||
| 9 | client: Client, | 10 | client: Client, |
| 10 | } | 11 | } |
| 11 | 12 | ||
| 12 | #[allow(dead_code)] | 13 | const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/"; |
| 14 | const LISTENBRAINZ_VALIDATE_URL: &str = "https://api.listenbrainz.org/1/validate-token"; | ||
| 15 | const LISTENBRAINZ_LISTENS_URL: &str = "https://api.listenbrainz.org/1/submit-listens"; | ||
| 16 | |||
| 13 | impl Scrobbler { | 17 | impl Scrobbler { |
| 14 | pub fn new( | 18 | pub fn new( |
| 15 | lastfm_api_key: Option<String>, | 19 | lastfm_api_key: Option<String>, |
| @@ -26,50 +30,303 @@ impl Scrobbler { | |||
| 26 | } | 30 | } |
| 27 | } | 31 | } |
| 28 | 32 | ||
| 33 | pub fn validate_listenbrainz_token( | ||
| 34 | &self, | ||
| 35 | ) -> Result<Option<String>, Box<dyn std::error::Error>> { | ||
| 36 | let Some(token) = &self.listenbrainz_token else { | ||
| 37 | return Ok(None); | ||
| 38 | }; | ||
| 39 | if token.is_empty() { | ||
| 40 | return Ok(None); | ||
| 41 | } | ||
| 42 | |||
| 43 | let resp = self | ||
| 44 | .client | ||
| 45 | .get(LISTENBRAINZ_VALIDATE_URL) | ||
| 46 | .header("Authorization", format!("Token {}", token)) | ||
| 47 | .send()?; | ||
| 48 | |||
| 49 | if !resp.status().is_success() { | ||
| 50 | eprintln!( | ||
| 51 | "scrobble: listenbrainz token validation failed: {}", | ||
| 52 | resp.status() | ||
| 53 | ); | ||
| 54 | return Ok(None); | ||
| 55 | } | ||
| 56 | |||
| 57 | let body: serde_json::Value = resp.json()?; | ||
| 58 | let valid = body.get("valid").and_then(|v| v.as_bool()).unwrap_or(false); | ||
| 59 | if valid { | ||
| 60 | let user = body | ||
| 61 | .get("user_name") | ||
| 62 | .and_then(|v| v.as_str()) | ||
| 63 | .map(|s| s.to_string()); | ||
| 64 | if let Some(ref u) = user { | ||
| 65 | eprintln!("scrobble: listenbrainz token valid (user: {})", u); | ||
| 66 | } else { | ||
| 67 | eprintln!("scrobble: listenbrainz token valid"); | ||
| 68 | } | ||
| 69 | Ok(user) | ||
| 70 | } else { | ||
| 71 | eprintln!("scrobble: listenbrainz token invalid — scrobbling will fail"); | ||
| 72 | Ok(None) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 29 | pub fn now_playing(&self, artist: &str, track: &str, album: Option<&str>) { | 76 | pub fn now_playing(&self, artist: &str, track: &str, album: Option<&str>) { |
| 30 | if let Some(api_key) = &self.lastfm_api_key { | 77 | if let (Some(api_key), Some(secret), Some(session_key)) = ( |
| 31 | let _ = self.send_lastfm_now_playing(api_key, artist, track, album); | 78 | &self.lastfm_api_key, |
| 79 | &self.lastfm_api_secret, | ||
| 80 | &self.lastfm_session_key, | ||
| 81 | ) && !api_key.is_empty() | ||
| 82 | && !secret.is_empty() | ||
| 83 | && !session_key.is_empty() | ||
| 84 | { | ||
| 85 | eprintln!("scrobble: now_playing -> lastfm '{} - {}'", artist, track); | ||
| 86 | let _ = | ||
| 87 | self.send_lastfm_now_playing(api_key, secret, session_key, artist, track, album); | ||
| 88 | } else { | ||
| 89 | eprintln!("scrobble: now_playing -> lastfm skipped (no credentials)"); | ||
| 90 | } | ||
| 91 | if let Some(token) = &self.listenbrainz_token | ||
| 92 | && !token.is_empty() | ||
| 93 | { | ||
| 94 | eprintln!( | ||
| 95 | "scrobble: now_playing -> listenbrainz '{} - {}'", | ||
| 96 | artist, track | ||
| 97 | ); | ||
| 98 | let _ = self.send_listenbrainz_now_playing(token, artist, track, album); | ||
| 99 | } else { | ||
| 100 | eprintln!("scrobble: now_playing -> listenbrainz skipped (no token)"); | ||
| 32 | } | 101 | } |
| 33 | } | 102 | } |
| 34 | 103 | ||
| 35 | pub fn scrobble(&self, artist: &str, track: &str, album: Option<&str>, timestamp: u64) { | 104 | pub fn scrobble(&self, artist: &str, track: &str, album: Option<&str>, timestamp: u64) { |
| 36 | if let Some(api_key) = &self.lastfm_api_key { | 105 | if let (Some(api_key), Some(secret), Some(session_key)) = ( |
| 37 | let _ = self.send_lastfm_scrobble(api_key, artist, track, album, timestamp); | 106 | &self.lastfm_api_key, |
| 107 | &self.lastfm_api_secret, | ||
| 108 | &self.lastfm_session_key, | ||
| 109 | ) && !api_key.is_empty() | ||
| 110 | && !secret.is_empty() | ||
| 111 | && !session_key.is_empty() | ||
| 112 | { | ||
| 113 | eprintln!( | ||
| 114 | "scrobble: scrobble -> lastfm '{} - {}' (ts={})", | ||
| 115 | artist, track, timestamp | ||
| 116 | ); | ||
| 117 | let _ = self.send_lastfm_scrobble( | ||
| 118 | api_key, | ||
| 119 | secret, | ||
| 120 | session_key, | ||
| 121 | artist, | ||
| 122 | track, | ||
| 123 | album, | ||
| 124 | timestamp, | ||
| 125 | ); | ||
| 126 | } else { | ||
| 127 | eprintln!("scrobble: scrobble -> lastfm skipped (no credentials)"); | ||
| 38 | } | 128 | } |
| 39 | if let Some(token) = &self.listenbrainz_token { | 129 | if let Some(token) = &self.listenbrainz_token |
| 130 | && !token.is_empty() | ||
| 131 | { | ||
| 132 | eprintln!( | ||
| 133 | "scrobble: scrobble -> listenbrainz '{} - {}' (ts={})", | ||
| 134 | artist, track, timestamp | ||
| 135 | ); | ||
| 40 | let _ = self.send_listenbrainz_scrobble(token, artist, track, album, timestamp); | 136 | let _ = self.send_listenbrainz_scrobble(token, artist, track, album, timestamp); |
| 137 | } else { | ||
| 138 | eprintln!("scrobble: scrobble -> listenbrainz skipped (no token)"); | ||
| 41 | } | 139 | } |
| 42 | } | 140 | } |
| 43 | 141 | ||
| 142 | fn sign_lastfm(params: &BTreeMap<&str, &str>, secret: &str) -> String { | ||
| 143 | let mut concat = String::new(); | ||
| 144 | for (key, value) in params { | ||
| 145 | concat.push_str(key); | ||
| 146 | concat.push_str(value); | ||
| 147 | } | ||
| 148 | concat.push_str(secret); | ||
| 149 | let mut hasher = Md5::new(); | ||
| 150 | hasher.update(concat.as_bytes()); | ||
| 151 | hex::encode(hasher.finalize()) | ||
| 152 | } | ||
| 153 | |||
| 154 | #[allow(clippy::too_many_arguments)] | ||
| 44 | fn send_lastfm_now_playing( | 155 | fn send_lastfm_now_playing( |
| 45 | &self, | 156 | &self, |
| 46 | _api_key: &str, | 157 | api_key: &str, |
| 47 | _artist: &str, | 158 | secret: &str, |
| 48 | _track: &str, | 159 | session_key: &str, |
| 49 | _album: Option<&str>, | 160 | artist: &str, |
| 161 | track: &str, | ||
| 162 | album: Option<&str>, | ||
| 50 | ) -> Result<(), Box<dyn std::error::Error>> { | 163 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 164 | let mut params = BTreeMap::from([ | ||
| 165 | ("method", "track.updateNowPlaying"), | ||
| 166 | ("artist", artist), | ||
| 167 | ("track", track), | ||
| 168 | ("api_key", api_key), | ||
| 169 | ("sk", session_key), | ||
| 170 | ]); | ||
| 171 | if let Some(album) = album { | ||
| 172 | params.insert("album", album); | ||
| 173 | } | ||
| 174 | let api_sig = Self::sign_lastfm(¶ms, secret); | ||
| 175 | params.insert("api_sig", &api_sig); | ||
| 176 | params.insert("format", "json"); | ||
| 177 | |||
| 178 | let resp = self.client.post(LASTFM_API_URL).form(¶ms).send()?; | ||
| 179 | if resp.status().is_success() { | ||
| 180 | eprintln!("scrobble: lastfm now_playing ok"); | ||
| 181 | } else { | ||
| 182 | eprintln!( | ||
| 183 | "scrobble: lastfm now_playing error: {} {}", | ||
| 184 | resp.status(), | ||
| 185 | resp.text().unwrap_or_default() | ||
| 186 | ); | ||
| 187 | } | ||
| 51 | Ok(()) | 188 | Ok(()) |
| 52 | } | 189 | } |
| 53 | 190 | ||
| 191 | #[allow(clippy::too_many_arguments)] | ||
| 54 | fn send_lastfm_scrobble( | 192 | fn send_lastfm_scrobble( |
| 55 | &self, | 193 | &self, |
| 56 | _api_key: &str, | 194 | api_key: &str, |
| 57 | _artist: &str, | 195 | secret: &str, |
| 58 | _track: &str, | 196 | session_key: &str, |
| 59 | _album: Option<&str>, | 197 | artist: &str, |
| 60 | _timestamp: u64, | 198 | track: &str, |
| 199 | album: Option<&str>, | ||
| 200 | timestamp: u64, | ||
| 61 | ) -> Result<(), Box<dyn std::error::Error>> { | 201 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 202 | let ts_str = timestamp.to_string(); | ||
| 203 | let mut params = BTreeMap::from([ | ||
| 204 | ("method", "track.scrobble"), | ||
| 205 | ("artist", artist), | ||
| 206 | ("track", track), | ||
| 207 | ("timestamp", &ts_str), | ||
| 208 | ("api_key", api_key), | ||
| 209 | ("sk", session_key), | ||
| 210 | ]); | ||
| 211 | if let Some(album) = album { | ||
| 212 | params.insert("album", album); | ||
| 213 | } | ||
| 214 | let api_sig = Self::sign_lastfm(¶ms, secret); | ||
| 215 | params.insert("api_sig", &api_sig); | ||
| 216 | params.insert("format", "json"); | ||
| 217 | |||
| 218 | let resp = self.client.post(LASTFM_API_URL).form(¶ms).send()?; | ||
| 219 | if resp.status().is_success() { | ||
| 220 | eprintln!("scrobble: lastfm scrobble ok"); | ||
| 221 | } else { | ||
| 222 | eprintln!( | ||
| 223 | "scrobble: lastfm scrobble error: {} {}", | ||
| 224 | resp.status(), | ||
| 225 | resp.text().unwrap_or_default() | ||
| 226 | ); | ||
| 227 | } | ||
| 228 | Ok(()) | ||
| 229 | } | ||
| 230 | |||
| 231 | fn send_listenbrainz_now_playing( | ||
| 232 | &self, | ||
| 233 | token: &str, | ||
| 234 | artist: &str, | ||
| 235 | track: &str, | ||
| 236 | album: Option<&str>, | ||
| 237 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 238 | let mut track_metadata = serde_json::json!({ | ||
| 239 | "artist_name": artist, | ||
| 240 | "track_name": track, | ||
| 241 | }); | ||
| 242 | if let Some(album) = album { | ||
| 243 | track_metadata["release_name"] = serde_json::json!(album); | ||
| 244 | } | ||
| 245 | |||
| 246 | let payload = serde_json::json!({ | ||
| 247 | "listen_type": "playing_now", | ||
| 248 | "payload": [{ | ||
| 249 | "track_metadata": track_metadata | ||
| 250 | }] | ||
| 251 | }); | ||
| 252 | |||
| 253 | let resp = self | ||
| 254 | .client | ||
| 255 | .post(LISTENBRAINZ_LISTENS_URL) | ||
| 256 | .header("Authorization", format!("Token {}", token)) | ||
| 257 | .json(&payload) | ||
| 258 | .send()?; | ||
| 259 | if resp.status().is_success() { | ||
| 260 | eprintln!("scrobble: listenbrainz now_playing ok"); | ||
| 261 | } else { | ||
| 262 | eprintln!( | ||
| 263 | "scrobble: listenbrainz now_playing error: {} {}", | ||
| 264 | resp.status(), | ||
| 265 | resp.text().unwrap_or_default() | ||
| 266 | ); | ||
| 267 | } | ||
| 62 | Ok(()) | 268 | Ok(()) |
| 63 | } | 269 | } |
| 64 | 270 | ||
| 65 | fn send_listenbrainz_scrobble( | 271 | fn send_listenbrainz_scrobble( |
| 66 | &self, | 272 | &self, |
| 67 | _token: &str, | 273 | token: &str, |
| 68 | _artist: &str, | 274 | artist: &str, |
| 69 | _track: &str, | 275 | track: &str, |
| 70 | _album: Option<&str>, | 276 | album: Option<&str>, |
| 71 | _timestamp: u64, | 277 | timestamp: u64, |
| 72 | ) -> Result<(), Box<dyn std::error::Error>> { | 278 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 279 | let mut track_metadata = serde_json::json!({ | ||
| 280 | "artist_name": artist, | ||
| 281 | "track_name": track, | ||
| 282 | }); | ||
| 283 | if let Some(album) = album { | ||
| 284 | track_metadata["release_name"] = serde_json::json!(album); | ||
| 285 | } | ||
| 286 | |||
| 287 | let payload = serde_json::json!({ | ||
| 288 | "listen_type": "single", | ||
| 289 | "payload": [{ | ||
| 290 | "listened_at": timestamp, | ||
| 291 | "track_metadata": track_metadata | ||
| 292 | }] | ||
| 293 | }); | ||
| 294 | |||
| 295 | let resp = self | ||
| 296 | .client | ||
| 297 | .post(LISTENBRAINZ_LISTENS_URL) | ||
| 298 | .header("Authorization", format!("Token {}", token)) | ||
| 299 | .json(&payload) | ||
| 300 | .send()?; | ||
| 301 | if resp.status().is_success() { | ||
| 302 | eprintln!("scrobble: listenbrainz scrobble ok"); | ||
| 303 | } else { | ||
| 304 | eprintln!( | ||
| 305 | "scrobble: listenbrainz scrobble error: {} {}", | ||
| 306 | resp.status(), | ||
| 307 | resp.text().unwrap_or_default() | ||
| 308 | ); | ||
| 309 | } | ||
| 73 | Ok(()) | 310 | Ok(()) |
| 74 | } | 311 | } |
| 75 | } | 312 | } |
| 313 | |||
| 314 | #[cfg(test)] | ||
| 315 | mod tests { | ||
| 316 | use super::*; | ||
| 317 | |||
| 318 | #[test] | ||
| 319 | fn test_sign_lastfm() { | ||
| 320 | let params = BTreeMap::from([ | ||
| 321 | ("api_key", "abc123"), | ||
| 322 | ("method", "track.scrobble"), | ||
| 323 | ("sk", "session_key"), | ||
| 324 | ("track", "Test Track"), | ||
| 325 | ("artist", "Test Artist"), | ||
| 326 | ("timestamp", "1234567890"), | ||
| 327 | ]); | ||
| 328 | let sig = Scrobbler::sign_lastfm(¶ms, "sekrit"); | ||
| 329 | assert_eq!(sig.len(), 32); | ||
| 330 | assert!(sig.chars().all(|c| c.is_ascii_hexdigit())); | ||
| 331 | } | ||
| 332 | } | ||
diff --git a/tests/queue.rs b/tests/queue.rs index a73c312..ccc6e54 100644 --- a/tests/queue.rs +++ b/tests/queue.rs | |||
| @@ -7,6 +7,7 @@ fn track(path: &str, title: Option<&str>, artist: Option<&str>, album: Option<&s | |||
| 7 | title: title.map(|s| s.to_string()), | 7 | title: title.map(|s| s.to_string()), |
| 8 | artist: artist.map(|s| s.to_string()), | 8 | artist: artist.map(|s| s.to_string()), |
| 9 | album: album.map(|s| s.to_string()), | 9 | album: album.map(|s| s.to_string()), |
| 10 | duration_secs: None, | ||
| 10 | } | 11 | } |
| 11 | } | 12 | } |
| 12 | 13 | ||
| @@ -16,6 +17,7 @@ fn track_no_meta(path: &str) -> Track { | |||
| 16 | title: None, | 17 | title: None, |
| 17 | artist: None, | 18 | artist: None, |
| 18 | album: None, | 19 | album: None, |
| 20 | duration_secs: None, | ||
| 19 | } | 21 | } |
| 20 | } | 22 | } |
| 21 | 23 | ||
diff --git a/tests/scrobbler.rs b/tests/scrobbler.rs index f326250..5453748 100644 --- a/tests/scrobbler.rs +++ b/tests/scrobbler.rs | |||
| @@ -24,54 +24,52 @@ fn test_scrobbler_new_with_none() { | |||
| 24 | } | 24 | } |
| 25 | 25 | ||
| 26 | #[test] | 26 | #[test] |
| 27 | fn test_scrobbler_now_playing_no_api_key() { | 27 | fn test_scrobbler_now_playing_no_creds() { |
| 28 | let scrobbler = Scrobbler::new(None, None, None, None); | 28 | let scrobbler = Scrobbler::new(None, None, None, None); |
| 29 | scrobbler.now_playing("Artist", "Track", Some("Album")); | 29 | scrobbler.now_playing("Artist", "Track", Some("Album")); |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | #[test] | 32 | #[test] |
| 33 | fn test_scrobbler_now_playing_with_api_key() { | 33 | fn test_scrobbler_now_playing_partial_lastfm() { |
| 34 | // Only api_key set — not enough to trigger Last.fm (needs secret + session_key too) | ||
| 34 | let scrobbler = Scrobbler::new(Some("api_key".to_string()), None, None, None); | 35 | let scrobbler = Scrobbler::new(Some("api_key".to_string()), None, None, None); |
| 35 | scrobbler.now_playing("Artist", "Track", Some("Album")); | 36 | scrobbler.now_playing("Artist", "Track", Some("Album")); |
| 36 | } | 37 | } |
| 37 | 38 | ||
| 38 | #[test] | 39 | #[test] |
| 39 | fn test_scrobbler_scrobble_no_keys() { | 40 | fn test_scrobbler_now_playing_no_album() { |
| 40 | let scrobbler = Scrobbler::new(None, None, None, None); | ||
| 41 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); | ||
| 42 | } | ||
| 43 | |||
| 44 | #[test] | ||
| 45 | fn test_scrobbler_scrobble_with_lastfm() { | ||
| 46 | let scrobbler = Scrobbler::new(Some("api_key".to_string()), None, None, None); | 41 | let scrobbler = Scrobbler::new(Some("api_key".to_string()), None, None, None); |
| 47 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); | 42 | scrobbler.now_playing("Artist", "Track", None); |
| 48 | } | 43 | } |
| 49 | 44 | ||
| 50 | #[test] | 45 | #[test] |
| 51 | fn test_scrobbler_scrobble_with_listenbrainz() { | 46 | fn test_scrobbler_scrobble_no_creds() { |
| 52 | let scrobbler = Scrobbler::new(None, None, None, Some("token".to_string())); | 47 | let scrobbler = Scrobbler::new(None, None, None, None); |
| 53 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); | 48 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); |
| 54 | } | 49 | } |
| 55 | 50 | ||
| 56 | #[test] | 51 | #[test] |
| 57 | fn test_scrobbler_scrobble_with_both() { | 52 | fn test_scrobbler_scrobble_no_album() { |
| 58 | let scrobbler = Scrobbler::new( | 53 | let scrobbler = Scrobbler::new(None, None, None, None); |
| 59 | Some("api_key".to_string()), | 54 | scrobbler.scrobble("Artist", "Track", None, 1234567890); |
| 60 | None, | ||
| 61 | None, | ||
| 62 | Some("token".to_string()), | ||
| 63 | ); | ||
| 64 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); | ||
| 65 | } | 55 | } |
| 66 | 56 | ||
| 67 | #[test] | 57 | #[test] |
| 68 | fn test_scrobbler_now_playing_no_album() { | 58 | fn test_scrobbler_scrobble_partial_lastfm() { |
| 59 | // Only api_key set — not enough to trigger Last.fm | ||
| 69 | let scrobbler = Scrobbler::new(Some("api_key".to_string()), None, None, None); | 60 | let scrobbler = Scrobbler::new(Some("api_key".to_string()), None, None, None); |
| 70 | scrobbler.now_playing("Artist", "Track", None); | 61 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); |
| 71 | } | 62 | } |
| 72 | 63 | ||
| 73 | #[test] | 64 | #[test] |
| 74 | fn test_scrobbler_scrobble_no_album() { | 65 | fn test_scrobbler_skip_empty_credentials() { |
| 75 | let scrobbler = Scrobbler::new(Some("api_key".to_string()), None, None, None); | 66 | // Empty strings should be treated as unset — no HTTP requests |
| 76 | scrobbler.scrobble("Artist", "Track", None, 1234567890); | 67 | let scrobbler = Scrobbler::new( |
| 68 | Some("".to_string()), | ||
| 69 | Some("".to_string()), | ||
| 70 | Some("".to_string()), | ||
| 71 | Some("".to_string()), | ||
| 72 | ); | ||
| 73 | scrobbler.now_playing("Artist", "Track", Some("Album")); | ||
| 74 | scrobbler.scrobble("Artist", "Track", Some("Album"), 1234567890); | ||
| 77 | } | 75 | } |
