aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock89
-rw-r--r--Cargo.toml6
-rw-r--r--TODO.md2
-rw-r--r--src/cli.rs8
-rw-r--r--src/daemon.rs325
-rw-r--r--src/queue.rs20
-rw-r--r--src/scrobbler.rs301
-rw-r--r--tests/queue.rs2
-rw-r--r--tests/scrobbler.rs48
9 files changed, 735 insertions, 66 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d4a7870..923e985 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -279,6 +279,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
279checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" 279checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
280 280
281[[package]] 281[[package]]
282name = "block-buffer"
283version = "0.12.0"
284source = "registry+https://github.com/rust-lang/crates.io-index"
285checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
286dependencies = [
287 "hybrid-array",
288]
289
290[[package]]
282name = "block2" 291name = "block2"
283version = "0.6.2" 292version = "0.6.2"
284source = "registry+https://github.com/rust-lang/crates.io-index" 293source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -456,6 +465,12 @@ dependencies = [
456] 465]
457 466
458[[package]] 467[[package]]
468name = "const-oid"
469version = "0.10.2"
470source = "registry+https://github.com/rust-lang/crates.io-index"
471checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
472
473[[package]]
459name = "core-foundation" 474name = "core-foundation"
460version = "0.9.4" 475version = "0.9.4"
461source = "registry+https://github.com/rust-lang/crates.io-index" 476source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -541,6 +556,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
541checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 556checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
542 557
543[[package]] 558[[package]]
559name = "crypto-common"
560version = "0.2.1"
561source = "registry+https://github.com/rust-lang/crates.io-index"
562checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
563dependencies = [
564 "hybrid-array",
565]
566
567[[package]]
544name = "ctrlc" 568name = "ctrlc"
545version = "3.5.2" 569version = "3.5.2"
546source = "registry+https://github.com/rust-lang/crates.io-index" 570source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -567,6 +591,17 @@ dependencies = [
567] 591]
568 592
569[[package]] 593[[package]]
594name = "digest"
595version = "0.11.3"
596source = "registry+https://github.com/rust-lang/crates.io-index"
597checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
598dependencies = [
599 "block-buffer",
600 "const-oid",
601 "crypto-common",
602]
603
604[[package]]
570name = "directories" 605name = "directories"
571version = "6.0.0" 606version = "6.0.0"
572source = "registry+https://github.com/rust-lang/crates.io-index" 607source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -768,6 +803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
768checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 803checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
769dependencies = [ 804dependencies = [
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"
814checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 850checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
815dependencies = [ 851dependencies = [
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"
970checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1009checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
971 1010
972[[package]] 1011[[package]]
1012name = "hybrid-array"
1013version = "0.4.12"
1014source = "registry+https://github.com/rust-lang/crates.io-index"
1015checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
1016dependencies = [
1017 "typenum",
1018]
1019
1020[[package]]
973name = "hyper" 1021name = "hyper"
974version = "1.9.0" 1022version = "1.9.0"
975source = "registry+https://github.com/rust-lang/crates.io-index" 1023source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1382,6 +1430,16 @@ dependencies = [
1382] 1430]
1383 1431
1384[[package]] 1432[[package]]
1433name = "md-5"
1434version = "0.11.0"
1435source = "registry+https://github.com/rust-lang/crates.io-index"
1436checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
1437dependencies = [
1438 "cfg-if",
1439 "digest",
1440]
1441
1442[[package]]
1385name = "memchr" 1443name = "memchr"
1386version = "2.8.0" 1444version = "2.8.0"
1387source = "registry+https://github.com/rust-lang/crates.io-index" 1445source = "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]]
1973name = "rsp" 2034name = "rsp"
1974version = "0.5.0" 2035version = "0.8.0"
1975dependencies = [ 2036dependencies = [
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"
2129checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2192checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
2130 2193
2131[[package]] 2194[[package]]
2195name = "ryu"
2196version = "1.0.23"
2197source = "registry+https://github.com/rust-lang/crates.io-index"
2198checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
2199
2200[[package]]
2132name = "same-file" 2201name = "same-file"
2133version = "1.0.6" 2202version = "1.0.6"
2134source = "registry+https://github.com/rust-lang/crates.io-index" 2203source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2245,6 +2314,18 @@ dependencies = [
2245] 2314]
2246 2315
2247[[package]] 2316[[package]]
2317name = "serde_urlencoded"
2318version = "0.7.1"
2319source = "registry+https://github.com/rust-lang/crates.io-index"
2320checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
2321dependencies = [
2322 "form_urlencoded",
2323 "itoa",
2324 "ryu",
2325 "serde",
2326]
2327
2328[[package]]
2248name = "shlex" 2329name = "shlex"
2249version = "1.3.0" 2330version = "1.3.0"
2250source = "registry+https://github.com/rust-lang/crates.io-index" 2331source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2879,6 +2960,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2879checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2960checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2880 2961
2881[[package]] 2962[[package]]
2963name = "typenum"
2964version = "1.20.0"
2965source = "registry+https://github.com/rust-lang/crates.io-index"
2966checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
2967
2968[[package]]
2882name = "uds_windows" 2969name = "uds_windows"
2883version = "1.2.1" 2970version = "1.2.1"
2884source = "registry+https://github.com/rust-lang/crates.io-index" 2971source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index afcc9cf..270ea7d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "rsp" 2name = "rsp"
3version = "0.5.0" 3version = "0.8.0"
4edition = "2024" 4edition = "2024"
5description = "Rust music player daemon with Unix socket IPC" 5description = "Rust music player daemon with Unix socket IPC"
6license = "GPL-3.0 or later" 6license = "GPL-3.0 or later"
@@ -23,7 +23,7 @@ directories = "6.0"
23serde = { version = "1.0.228", features = ["derive"] } 23serde = { version = "1.0.228", features = ["derive"] }
24toml = "1.1.2" 24toml = "1.1.2"
25tokio = { version = "1.52.2", features = ["full"] } 25tokio = { version = "1.52.2", features = ["full"] }
26reqwest = { version = "0.13.3", features = ["json"] } 26reqwest = { version = "0.13.3", features = ["json", "form", "blocking"] }
27symphonia = { version = "0.5.5", features = ["all"] } 27symphonia = { version = "0.5.5", features = ["all"] }
28cpal = "0.17.3" 28cpal = "0.17.3"
29chrono = "0.4.44" 29chrono = "0.4.44"
@@ -34,6 +34,8 @@ ctrlc = "3.5.2"
34rand = "0.10.1" 34rand = "0.10.1"
35notify-rust = "4.17.0" 35notify-rust = "4.17.0"
36terminal_size = "0.4.4" 36terminal_size = "0.4.4"
37md-5 = "0.11.0"
38hex = "0.4.3"
37 39
38[dev-dependencies] 40[dev-dependencies]
39tempfile = "3.17" 41tempfile = "3.17"
diff --git a/TODO.md b/TODO.md
index c66ae50..d278d6d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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
diff --git a/src/cli.rs b/src/cli.rs
index 7d56de6..f9a3c6f 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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
110impl Cli { 112impl 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;
2use crate::config::Config; 2use crate::config::Config;
3use crate::notification; 3use crate::notification;
4use crate::queue::{Queue, ShuffleMode, Track}; 4use crate::queue::{Queue, ShuffleMode, Track};
5use crate::scrobbler::Scrobbler;
5use std::io::{Read, Write}; 6use std::io::{Read, Write};
6use std::os::unix::net::{UnixListener, UnixStream}; 7use std::os::unix::net::{UnixListener, UnixStream};
7use std::path::{Path, PathBuf}; 8use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, Ordering}; 9use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex}; 10use std::sync::{Arc, Mutex, RwLock};
10use std::thread; 11use std::thread;
11use std::time::Duration; 12use std::time::{Duration, Instant, SystemTime};
13
14struct ScrobbleState {
15 track: Option<Track>,
16 start_time: Option<Instant>,
17 timestamp: Option<u64>,
18}
19
20impl 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)]
83mod 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
154fn 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
182fn 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
13pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { 192pub 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
18pub fn extract_metadata(path: &Path) -> Option<AudioMetadata> { 19pub 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
87impl Track { 104impl 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 @@
1use reqwest::Client; 1use md5::{Digest, Md5};
2use reqwest::blocking::Client;
3use std::collections::BTreeMap;
2 4
3#[allow(dead_code)]
4pub struct Scrobbler { 5pub 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)] 13const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
14const LISTENBRAINZ_VALIDATE_URL: &str = "https://api.listenbrainz.org/1/validate-token";
15const LISTENBRAINZ_LISTENS_URL: &str = "https://api.listenbrainz.org/1/submit-listens";
16
13impl Scrobbler { 17impl 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(&params, secret);
175 params.insert("api_sig", &api_sig);
176 params.insert("format", "json");
177
178 let resp = self.client.post(LASTFM_API_URL).form(&params).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(&params, secret);
215 params.insert("api_sig", &api_sig);
216 params.insert("format", "json");
217
218 let resp = self.client.post(LASTFM_API_URL).form(&params).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)]
315mod 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(&params, "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]
27fn test_scrobbler_now_playing_no_api_key() { 27fn 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]
33fn test_scrobbler_now_playing_with_api_key() { 33fn 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]
39fn test_scrobbler_scrobble_no_keys() { 40fn 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]
45fn 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]
51fn test_scrobbler_scrobble_with_listenbrainz() { 46fn 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]
57fn test_scrobbler_scrobble_with_both() { 52fn 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]
68fn test_scrobbler_now_playing_no_album() { 58fn 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]
74fn test_scrobbler_scrobble_no_album() { 65fn 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}