aboutsummaryrefslogtreecommitdiffstats
path: root/src/daemon.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/daemon.rs')
-rw-r--r--src/daemon.rs296
1 files changed, 191 insertions, 105 deletions
diff --git a/src/daemon.rs b/src/daemon.rs
index c794495..5ad6654 100644
--- a/src/daemon.rs
+++ b/src/daemon.rs
@@ -18,14 +18,20 @@ use std::sync::{Arc, Mutex, RwLock};
18use std::thread; 18use std::thread;
19use std::time::{Duration, Instant, SystemTime}; 19use std::time::{Duration, Instant, SystemTime};
20 20
21struct ScrobbleState { 21pub struct ScrobbleState {
22 track: Option<Track>, 22 pub track: Option<Track>,
23 start_time: Option<Instant>, 23 pub start_time: Option<Instant>,
24 timestamp: Option<u64>, 24 pub timestamp: Option<u64>,
25}
26
27impl Default for ScrobbleState {
28 fn default() -> Self {
29 Self::new()
30 }
25} 31}
26 32
27impl ScrobbleState { 33impl ScrobbleState {
28 fn new() -> Self { 34 pub fn new() -> Self {
29 ScrobbleState { 35 ScrobbleState {
30 track: None, 36 track: None,
31 start_time: None, 37 start_time: None,
@@ -33,7 +39,7 @@ impl ScrobbleState {
33 } 39 }
34 } 40 }
35 41
36 fn start(&mut self, track: Track) { 42 pub fn start(&mut self, track: Track) {
37 if track.is_stream { 43 if track.is_stream {
38 crate::debug_eprintln(format_args!( 44 crate::debug_eprintln(format_args!(
39 "scrobble: not tracking stream '{}'", 45 "scrobble: not tracking stream '{}'",
@@ -57,7 +63,7 @@ impl ScrobbleState {
57 self.timestamp = Some(ts); 63 self.timestamp = Some(ts);
58 } 64 }
59 65
60 fn should_scrobble(&self) -> bool { 66 pub fn should_scrobble(&self) -> bool {
61 let (Some(track), Some(start)) = (&self.track, self.start_time) else { 67 let (Some(track), Some(start)) = (&self.track, self.start_time) else {
62 crate::debug_eprintln(format_args!( 68 crate::debug_eprintln(format_args!(
63 "scrobble: should_scrobble false — no track or start_time" 69 "scrobble: should_scrobble false — no track or start_time"
@@ -78,7 +84,7 @@ impl ScrobbleState {
78 result 84 result
79 } 85 }
80 86
81 fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool { 87 pub fn should_scrobble_with_params(elapsed: u64, duration_secs: Option<f64>) -> bool {
82 if elapsed < 30 { 88 if elapsed < 30 {
83 return false; 89 return false;
84 } 90 }
@@ -89,86 +95,13 @@ impl ScrobbleState {
89 elapsed >= threshold 95 elapsed >= threshold
90 } 96 }
91 97
92 fn clear(&mut self) { 98 pub fn clear(&mut self) {
93 self.track = None; 99 self.track = None;
94 self.start_time = None; 100 self.start_time = None;
95 self.timestamp = None; 101 self.timestamp = None;
96 } 102 }
97} 103}
98 104
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn test_scrobble_state_new() {
105 let s = ScrobbleState::new();
106 assert!(s.track.is_none());
107 assert!(s.start_time.is_none());
108 assert!(s.timestamp.is_none());
109 }
110
111 #[test]
112 fn test_scrobble_state_clear() {
113 let mut s = ScrobbleState::new();
114 let t = Track {
115 path: PathBuf::from("/test/song.mp3"),
116 title: Some("Test".into()),
117 artist: Some("Artist".into()),
118 album: None,
119 duration_secs: Some(200.0),
120 is_stream: false,
121 };
122 s.start(t);
123 assert!(s.track.is_some());
124 s.clear();
125 assert!(s.track.is_none());
126 assert!(s.start_time.is_none());
127 assert!(s.timestamp.is_none());
128 }
129
130 #[test]
131 fn test_should_scrobble_without_track_returns_false() {
132 assert!(!ScrobbleState::new().should_scrobble());
133 }
134
135 #[test]
136 fn test_should_scrobble_threshold_below_30_seconds() {
137 assert!(!ScrobbleState::should_scrobble_with_params(29, Some(200.0)));
138 }
139
140 #[test]
141 fn test_should_scrobble_threshold_half_duration() {
142 assert!(ScrobbleState::should_scrobble_with_params(100, Some(200.0)));
143 }
144
145 #[test]
146 fn test_should_scrobble_threshold_below_half_duration() {
147 assert!(!ScrobbleState::should_scrobble_with_params(80, Some(200.0)));
148 }
149
150 #[test]
151 fn test_should_scrobble_threshold_capped_at_4min() {
152 assert!(ScrobbleState::should_scrobble_with_params(240, Some(600.0)));
153 assert!(!ScrobbleState::should_scrobble_with_params(
154 239,
155 Some(600.0)
156 ));
157 }
158
159 #[test]
160 fn test_should_scrobble_no_duration_requires_4min() {
161 assert!(ScrobbleState::should_scrobble_with_params(240, None));
162 assert!(!ScrobbleState::should_scrobble_with_params(239, None));
163 }
164
165 #[test]
166 fn test_should_scrobble_short_song() {
167 assert!(ScrobbleState::should_scrobble_with_params(30, Some(60.0)));
168 assert!(!ScrobbleState::should_scrobble_with_params(15, Some(60.0)));
169 }
170}
171
172fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { 105fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) {
173 if let Some(ref track) = state.track 106 if let Some(ref track) = state.track
174 && track.is_stream 107 && track.is_stream
@@ -747,30 +680,40 @@ fn process_command(
747 } else { 680 } else {
748 match Config::load() { 681 match Config::load() {
749 Ok(config) => match crate::database::Database::open(&config) { 682 Ok(config) => match crate::database::Database::open(&config) {
750 Ok(db) => match db.search_tracks(query_str) { 683 Ok(db) => {
751 Ok(tracks) if !tracks.is_empty() => { 684 let tracks = match db.resolve_path(query_str) {
752 let db_track = &tracks[0]; 685 Ok(Some(t)) => t,
753 let album_tracks = db 686 Ok(None) => match db.search_tracks(query_str) {
754 .get_album_tracks_after( 687 Ok(t) => t,
755 db_track.album_id, 688 Err(e) => return format!("Database error: {}\n", e),
756 db_track.track_number, 689 },
757 &db_track.path, 690 Err(e) => return format!("Database error: {}\n", e),
758 ) 691 };
759 .unwrap_or_default(); 692 if tracks.is_empty() {
760 let display = format!(
761 "{} - {}",
762 db_track.artist_name.as_deref().unwrap_or("unknown"),
763 db_track.title,
764 );
765 let paths: Vec<PathBuf> =
766 album_tracks.into_iter().map(|t| t.path).collect();
767 (db_track.path.clone(), paths, display)
768 }
769 Ok(_) => {
770 return format!("No tracks found matching '{}'\n", query_str); 693 return format!("No tracks found matching '{}'\n", query_str);
771 } 694 }
772 Err(e) => return format!("Database error: {}\n", e), 695 let db_track = &tracks[0];
773 }, 696 let album_paths: Vec<PathBuf> = if tracks.len() > 1 {
697 // resolve_path returned full album — use remaining tracks
698 tracks[1..].iter().map(|t| t.path.clone()).collect()
699 } else {
700 db.get_album_tracks_after(
701 db_track.album_id,
702 db_track.track_number,
703 &db_track.path,
704 )
705 .unwrap_or_default()
706 .into_iter()
707 .map(|t| t.path)
708 .collect()
709 };
710 let display = format!(
711 "{} - {}",
712 db_track.artist_name.as_deref().unwrap_or("unknown"),
713 db_track.title,
714 );
715 (db_track.path.clone(), album_paths, display)
716 }
774 Err(e) => return format!("Database error: {}\n", e), 717 Err(e) => return format!("Database error: {}\n", e),
775 }, 718 },
776 Err(e) => return format!("Config error: {}\n", e), 719 Err(e) => return format!("Config error: {}\n", e),
@@ -862,6 +805,24 @@ fn process_command(
862 } else { 805 } else {
863 path_str.to_string() 806 path_str.to_string()
864 }; 807 };
808 // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly
809 let path_str = path_str.trim_end_matches('/').to_string();
810
811 // Unescape backslash sequences (e.g., \! → !) so shell escaping works
812 let path_str = {
813 let mut unescaped = String::with_capacity(path_str.len());
814 let mut chars = path_str.chars();
815 while let Some(c) = chars.next() {
816 if c == '\\' {
817 if let Some(next) = chars.next() {
818 unescaped.push(next);
819 }
820 } else {
821 unescaped.push(c);
822 }
823 }
824 unescaped
825 };
865 826
866 if path_str.is_empty() { 827 if path_str.is_empty() {
867 return "Usage: play [now|next|last] <path>\n".to_string(); 828 return "Usage: play [now|next|last] <path>\n".to_string();
@@ -1052,7 +1013,132 @@ fn process_command(
1052 _ => format!("Error: No audio files found in '{}'\n", path_str), 1013 _ => format!("Error: No audio files found in '{}'\n", path_str),
1053 } 1014 }
1054 } else { 1015 } else {
1055 format!("File not found: {}\n", path_str) 1016 match Config::load() {
1017 Ok(config) => match crate::database::Database::open(&config) {
1018 Ok(db) => {
1019 // Collect tracks: try hierarchical path first, fallback to search
1020 let tracks = match db.resolve_path(&path_str) {
1021 Ok(Some(t)) => t,
1022 Ok(None) => {
1023 let query = path_str
1024 .rsplit_once('/')
1025 .and_then(|(_, after)| {
1026 (!after.is_empty()).then_some(after)
1027 })
1028 .unwrap_or(&path_str);
1029 match db.search_tracks(query) {
1030 Ok(t) => t,
1031 Err(e) => return format!("Database error: {}\n", e),
1032 }
1033 }
1034 Err(e) => return format!("Database error: {}\n", e),
1035 };
1036
1037 if tracks.is_empty() {
1038 return format!("No tracks found matching '{}'\n", path_str);
1039 }
1040
1041 let is_album_play = tracks.len() > 1;
1042 if let Ok(mut q) = queue.lock() {
1043 let was_empty = q.is_empty();
1044 match position {
1045 "now" => {
1046 if let Ok(mut s) = scrobble_state.lock() {
1047 try_scrobble_current(scrobbler, &mut s);
1048 }
1049 for t in tracks.iter().rev() {
1050 q.insert_after_current(t.path.clone());
1051 }
1052 if !was_empty {
1053 let new_pos = q.position() + 1;
1054 q.set_position(new_pos);
1055 }
1056 if let Some(track) = q.track_at(q.position()) {
1057 audio.play(track.path.clone());
1058 send_now_playing(scrobbler, track);
1059 if let Ok(mut s) = scrobble_state.lock() {
1060 s.start(track.clone());
1061 }
1062 let fmt = Config::load()
1063 .ok()
1064 .and_then(|c| c.display_format.clone());
1065 notification::notify_track(track, fmt.as_deref());
1066 }
1067 if is_album_play {
1068 format!(
1069 "Playing now: {} tracks from {}\n",
1070 tracks.len(),
1071 path_str
1072 )
1073 } else {
1074 format!("Playing now: {}\n", path_str)
1075 }
1076 }
1077 "next" => {
1078 for t in tracks.iter().rev() {
1079 q.insert_after_current(t.path.clone());
1080 }
1081 if is_album_play {
1082 format!(
1083 "Queued next: {} tracks from {}\n",
1084 tracks.len(),
1085 path_str
1086 )
1087 } else {
1088 format!("Queued next: {}\n", path_str)
1089 }
1090 }
1091 _ => {
1092 let was_playing = audio.is_playing();
1093 for t in &tracks {
1094 q.add_track(t.path.clone());
1095 }
1096 if was_empty && !was_playing {
1097 if is_album_play {
1098 q.shuffle_initial();
1099 }
1100 if let Some(track) = q.current() {
1101 audio.play(track.path.clone());
1102 send_now_playing(scrobbler, track);
1103 if let Ok(mut s) = scrobble_state.lock() {
1104 s.start(track.clone());
1105 }
1106 let fmt = Config::load()
1107 .ok()
1108 .and_then(|c| c.display_format.clone());
1109 notification::notify_track(
1110 track,
1111 fmt.as_deref(),
1112 );
1113 }
1114 if is_album_play {
1115 format!(
1116 "Playing: {} tracks from {}\n",
1117 tracks.len(),
1118 path_str
1119 )
1120 } else {
1121 format!("Playing: {}\n", path_str)
1122 }
1123 } else if is_album_play {
1124 format!(
1125 "Queued {} tracks from: {}\n",
1126 tracks.len(),
1127 path_str
1128 )
1129 } else {
1130 format!("Queued: {}\n", path_str)
1131 }
1132 }
1133 }
1134 } else {
1135 "Error accessing queue\n".to_string()
1136 }
1137 }
1138 Err(e) => format!("Database error: {}\n", e),
1139 },
1140 Err(e) => format!("Config error: {}\n", e),
1141 }
1056 } 1142 }
1057 } 1143 }
1058 } 1144 }