diff options
Diffstat (limited to '')
| -rw-r--r-- | src/daemon.rs | 296 |
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}; | |||
| 18 | use std::thread; | 18 | use std::thread; |
| 19 | use std::time::{Duration, Instant, SystemTime}; | 19 | use std::time::{Duration, Instant, SystemTime}; |
| 20 | 20 | ||
| 21 | struct ScrobbleState { | 21 | pub 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 | |||
| 27 | impl Default for ScrobbleState { | ||
| 28 | fn default() -> Self { | ||
| 29 | Self::new() | ||
| 30 | } | ||
| 25 | } | 31 | } |
| 26 | 32 | ||
| 27 | impl ScrobbleState { | 33 | impl 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)] | ||
| 100 | mod 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 | |||
| 172 | fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { | 105 | fn 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 | } |
