diff options
| author | salmonik <raisersoft@proton.me> | 2026-06-04 00:21:46 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-06-04 00:21:46 +0200 |
| commit | 1e48765ab39a679abcc4eac6927ad3bb58f7b438 (patch) | |
| tree | 5554f2a06bde693d4d9c39ed26f80677d61da991 /src | |
| parent | fix no metadata songs handling (diff) | |
| download | rsplayer-1e48765ab39a679abcc4eac6927ad3bb58f7b438.tar.gz rsplayer-1e48765ab39a679abcc4eac6927ad3bb58f7b438.zip | |
dirty, broken commit
Diffstat (limited to '')
| -rw-r--r-- | src/cli.rs | 36 | ||||
| -rw-r--r-- | src/daemon.rs | 183 | ||||
| -rw-r--r-- | src/database.rs | 136 |
3 files changed, 331 insertions, 24 deletions
| @@ -92,6 +92,16 @@ pub enum Commands { | |||
| 92 | #[arg(value_name = "SHELL")] | 92 | #[arg(value_name = "SHELL")] |
| 93 | shell: Option<String>, | 93 | shell: Option<String>, |
| 94 | }, | 94 | }, |
| 95 | #[clap(hide = true)] | ||
| 96 | /// Complete artists, albums, or tracks from database | ||
| 97 | DbComplete { | ||
| 98 | /// Entity type: artists, albums, or tracks | ||
| 99 | #[arg(value_name = "ENTITY")] | ||
| 100 | entity: String, | ||
| 101 | /// Optional prefix to match | ||
| 102 | #[arg(value_name = "PREFIX")] | ||
| 103 | prefix: Option<String>, | ||
| 104 | }, | ||
| 95 | } | 105 | } |
| 96 | 106 | ||
| 97 | #[derive(Subcommand)] | 107 | #[derive(Subcommand)] |
| @@ -235,11 +245,35 @@ impl Cli { | |||
| 235 | } | 245 | } |
| 236 | } | 246 | } |
| 237 | } | 247 | } |
| 248 | Commands::DbComplete { entity, prefix } => { | ||
| 249 | let config = match Config::load() { | ||
| 250 | Ok(c) => c, | ||
| 251 | Err(_) => return Ok(()), | ||
| 252 | }; | ||
| 253 | let db = match Database::open(&config) { | ||
| 254 | Ok(d) => d, | ||
| 255 | Err(_) => return Ok(()), | ||
| 256 | }; | ||
| 257 | let prefix = prefix.as_deref().unwrap_or(""); | ||
| 258 | let results: Vec<String> = match entity.as_str() { | ||
| 259 | "artists" => db.complete_artists(prefix).unwrap_or_default(), | ||
| 260 | "albums" => db.complete_albums(prefix).unwrap_or_default(), | ||
| 261 | "tracks" => db.complete_tracks(prefix).unwrap_or_default(), | ||
| 262 | _ => Vec::new(), | ||
| 263 | }; | ||
| 264 | let suffix = match entity.as_str() { | ||
| 265 | "artists" | "albums" => "/", | ||
| 266 | _ => "", | ||
| 267 | }; | ||
| 268 | for r in &results { | ||
| 269 | println!("{r}{suffix}"); | ||
| 270 | } | ||
| 271 | } | ||
| 238 | Commands::Completions { shell } => { | 272 | Commands::Completions { shell } => { |
| 239 | let full = Cli::command(); | 273 | let full = Cli::command(); |
| 240 | let subcommands: Vec<Command> = full | 274 | let subcommands: Vec<Command> = full |
| 241 | .get_subcommands() | 275 | .get_subcommands() |
| 242 | .filter(|s| s.get_name() != "completions") | 276 | .filter(|s| s.get_name() != "completions" && s.get_name() != "db-complete") |
| 243 | .cloned() | 277 | .cloned() |
| 244 | .collect(); | 278 | .collect(); |
| 245 | let cmd_name = full.get_name().to_string(); | 279 | let cmd_name = full.get_name().to_string(); |
diff --git a/src/daemon.rs b/src/daemon.rs index c794495..d34305a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs | |||
| @@ -747,30 +747,40 @@ fn process_command( | |||
| 747 | } else { | 747 | } else { |
| 748 | match Config::load() { | 748 | match Config::load() { |
| 749 | Ok(config) => match crate::database::Database::open(&config) { | 749 | Ok(config) => match crate::database::Database::open(&config) { |
| 750 | Ok(db) => match db.search_tracks(query_str) { | 750 | Ok(db) => { |
| 751 | Ok(tracks) if !tracks.is_empty() => { | 751 | let tracks = match db.resolve_path(query_str) { |
| 752 | let db_track = &tracks[0]; | 752 | Ok(Some(t)) => t, |
| 753 | let album_tracks = db | 753 | Ok(None) => match db.search_tracks(query_str) { |
| 754 | .get_album_tracks_after( | 754 | Ok(t) => t, |
| 755 | db_track.album_id, | 755 | Err(e) => return format!("Database error: {}\n", e), |
| 756 | db_track.track_number, | 756 | }, |
| 757 | &db_track.path, | 757 | Err(e) => return format!("Database error: {}\n", e), |
| 758 | ) | 758 | }; |
| 759 | .unwrap_or_default(); | 759 | 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); | 760 | return format!("No tracks found matching '{}'\n", query_str); |
| 771 | } | 761 | } |
| 772 | Err(e) => return format!("Database error: {}\n", e), | 762 | let db_track = &tracks[0]; |
| 773 | }, | 763 | let album_paths: Vec<PathBuf> = if tracks.len() > 1 { |
| 764 | // resolve_path returned full album — use remaining tracks | ||
| 765 | tracks[1..].iter().map(|t| t.path.clone()).collect() | ||
| 766 | } else { | ||
| 767 | db.get_album_tracks_after( | ||
| 768 | db_track.album_id, | ||
| 769 | db_track.track_number, | ||
| 770 | &db_track.path, | ||
| 771 | ) | ||
| 772 | .unwrap_or_default() | ||
| 773 | .into_iter() | ||
| 774 | .map(|t| t.path) | ||
| 775 | .collect() | ||
| 776 | }; | ||
| 777 | let display = format!( | ||
| 778 | "{} - {}", | ||
| 779 | db_track.artist_name.as_deref().unwrap_or("unknown"), | ||
| 780 | db_track.title, | ||
| 781 | ); | ||
| 782 | (db_track.path.clone(), album_paths, display) | ||
| 783 | } | ||
| 774 | Err(e) => return format!("Database error: {}\n", e), | 784 | Err(e) => return format!("Database error: {}\n", e), |
| 775 | }, | 785 | }, |
| 776 | Err(e) => return format!("Config error: {}\n", e), | 786 | Err(e) => return format!("Config error: {}\n", e), |
| @@ -862,6 +872,8 @@ fn process_command( | |||
| 862 | } else { | 872 | } else { |
| 863 | path_str.to_string() | 873 | path_str.to_string() |
| 864 | }; | 874 | }; |
| 875 | // Strip trailing slash so autocompleted Artist/Album/ resolves cleanly | ||
| 876 | let path_str = path_str.trim_end_matches('/').to_string(); | ||
| 865 | 877 | ||
| 866 | if path_str.is_empty() { | 878 | if path_str.is_empty() { |
| 867 | return "Usage: play [now|next|last] <path>\n".to_string(); | 879 | return "Usage: play [now|next|last] <path>\n".to_string(); |
| @@ -1052,7 +1064,132 @@ fn process_command( | |||
| 1052 | _ => format!("Error: No audio files found in '{}'\n", path_str), | 1064 | _ => format!("Error: No audio files found in '{}'\n", path_str), |
| 1053 | } | 1065 | } |
| 1054 | } else { | 1066 | } else { |
| 1055 | format!("File not found: {}\n", path_str) | 1067 | match Config::load() { |
| 1068 | Ok(config) => match crate::database::Database::open(&config) { | ||
| 1069 | Ok(db) => { | ||
| 1070 | // Collect tracks: try hierarchical path first, fallback to search | ||
| 1071 | let tracks = match db.resolve_path(&path_str) { | ||
| 1072 | Ok(Some(t)) => t, | ||
| 1073 | Ok(None) => { | ||
| 1074 | let query = path_str | ||
| 1075 | .rsplit_once('/') | ||
| 1076 | .and_then(|(_, after)| { | ||
| 1077 | (!after.is_empty()).then_some(after) | ||
| 1078 | }) | ||
| 1079 | .unwrap_or(&path_str); | ||
| 1080 | match db.search_tracks(query) { | ||
| 1081 | Ok(t) => t, | ||
| 1082 | Err(e) => return format!("Database error: {}\n", e), | ||
| 1083 | } | ||
| 1084 | } | ||
| 1085 | Err(e) => return format!("Database error: {}\n", e), | ||
| 1086 | }; | ||
| 1087 | |||
| 1088 | if tracks.is_empty() { | ||
| 1089 | return format!("No tracks found matching '{}'\n", path_str); | ||
| 1090 | } | ||
| 1091 | |||
| 1092 | let is_album_play = tracks.len() > 1; | ||
| 1093 | if let Ok(mut q) = queue.lock() { | ||
| 1094 | let was_empty = q.is_empty(); | ||
| 1095 | match position { | ||
| 1096 | "now" => { | ||
| 1097 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 1098 | try_scrobble_current(scrobbler, &mut s); | ||
| 1099 | } | ||
| 1100 | for t in tracks.iter().rev() { | ||
| 1101 | q.insert_after_current(t.path.clone()); | ||
| 1102 | } | ||
| 1103 | if !was_empty { | ||
| 1104 | let new_pos = q.position() + 1; | ||
| 1105 | q.set_position(new_pos); | ||
| 1106 | } | ||
| 1107 | if let Some(track) = q.track_at(q.position()) { | ||
| 1108 | audio.play(track.path.clone()); | ||
| 1109 | send_now_playing(scrobbler, track); | ||
| 1110 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 1111 | s.start(track.clone()); | ||
| 1112 | } | ||
| 1113 | let fmt = Config::load() | ||
| 1114 | .ok() | ||
| 1115 | .and_then(|c| c.display_format.clone()); | ||
| 1116 | notification::notify_track(track, fmt.as_deref()); | ||
| 1117 | } | ||
| 1118 | if is_album_play { | ||
| 1119 | format!( | ||
| 1120 | "Playing now: {} tracks from {}\n", | ||
| 1121 | tracks.len(), | ||
| 1122 | path_str | ||
| 1123 | ) | ||
| 1124 | } else { | ||
| 1125 | format!("Playing now: {}\n", path_str) | ||
| 1126 | } | ||
| 1127 | } | ||
| 1128 | "next" => { | ||
| 1129 | for t in tracks.iter().rev() { | ||
| 1130 | q.insert_after_current(t.path.clone()); | ||
| 1131 | } | ||
| 1132 | if is_album_play { | ||
| 1133 | format!( | ||
| 1134 | "Queued next: {} tracks from {}\n", | ||
| 1135 | tracks.len(), | ||
| 1136 | path_str | ||
| 1137 | ) | ||
| 1138 | } else { | ||
| 1139 | format!("Queued next: {}\n", path_str) | ||
| 1140 | } | ||
| 1141 | } | ||
| 1142 | _ => { | ||
| 1143 | let was_playing = audio.is_playing(); | ||
| 1144 | for t in &tracks { | ||
| 1145 | q.add_track(t.path.clone()); | ||
| 1146 | } | ||
| 1147 | if was_empty && !was_playing { | ||
| 1148 | if is_album_play { | ||
| 1149 | q.shuffle_initial(); | ||
| 1150 | } | ||
| 1151 | if let Some(track) = q.current() { | ||
| 1152 | audio.play(track.path.clone()); | ||
| 1153 | send_now_playing(scrobbler, track); | ||
| 1154 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 1155 | s.start(track.clone()); | ||
| 1156 | } | ||
| 1157 | let fmt = Config::load() | ||
| 1158 | .ok() | ||
| 1159 | .and_then(|c| c.display_format.clone()); | ||
| 1160 | notification::notify_track( | ||
| 1161 | track, | ||
| 1162 | fmt.as_deref(), | ||
| 1163 | ); | ||
| 1164 | } | ||
| 1165 | if is_album_play { | ||
| 1166 | format!( | ||
| 1167 | "Playing: {} tracks from {}\n", | ||
| 1168 | tracks.len(), | ||
| 1169 | path_str | ||
| 1170 | ) | ||
| 1171 | } else { | ||
| 1172 | format!("Playing: {}\n", path_str) | ||
| 1173 | } | ||
| 1174 | } else if is_album_play { | ||
| 1175 | format!( | ||
| 1176 | "Queued {} tracks from: {}\n", | ||
| 1177 | tracks.len(), | ||
| 1178 | path_str | ||
| 1179 | ) | ||
| 1180 | } else { | ||
| 1181 | format!("Queued: {}\n", path_str) | ||
| 1182 | } | ||
| 1183 | } | ||
| 1184 | } | ||
| 1185 | } else { | ||
| 1186 | "Error accessing queue\n".to_string() | ||
| 1187 | } | ||
| 1188 | } | ||
| 1189 | Err(e) => format!("Database error: {}\n", e), | ||
| 1190 | }, | ||
| 1191 | Err(e) => format!("Config error: {}\n", e), | ||
| 1192 | } | ||
| 1056 | } | 1193 | } |
| 1057 | } | 1194 | } |
| 1058 | } | 1195 | } |
diff --git a/src/database.rs b/src/database.rs index b80ad4a..00d970a 100644 --- a/src/database.rs +++ b/src/database.rs | |||
| @@ -45,6 +45,10 @@ pub struct Database { | |||
| 45 | 45 | ||
| 46 | #[allow(dead_code)] | 46 | #[allow(dead_code)] |
| 47 | impl Database { | 47 | impl Database { |
| 48 | pub fn conn(&self) -> &Connection { | ||
| 49 | &self.conn | ||
| 50 | } | ||
| 51 | |||
| 48 | pub fn open(_config: &crate::config::Config) -> SqlResult<Self> { | 52 | pub fn open(_config: &crate::config::Config) -> SqlResult<Self> { |
| 49 | let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { | 53 | let proj_dirs = ProjectDirs::from("com", "rsplayer", "rsplayer").ok_or_else(|| { |
| 50 | rusqlite::Error::InvalidPath("Failed to get project directories".into()) | 54 | rusqlite::Error::InvalidPath("Failed to get project directories".into()) |
| @@ -269,6 +273,138 @@ impl Database { | |||
| 269 | self.get_album_tracks_after(album_id, track_number, &path) | 273 | self.get_album_tracks_after(album_id, track_number, &path) |
| 270 | } | 274 | } |
| 271 | 275 | ||
| 276 | pub fn complete_artists(&self, prefix: &str) -> SqlResult<Vec<String>> { | ||
| 277 | let pattern = format!("%{}%", prefix); | ||
| 278 | let mut stmt = self | ||
| 279 | .conn | ||
| 280 | .prepare("SELECT name FROM artists WHERE name LIKE ?1 ORDER BY name LIMIT 25")?; | ||
| 281 | let rows = stmt.query_map([&pattern], |row| row.get::<_, String>(0))?; | ||
| 282 | rows.collect() | ||
| 283 | } | ||
| 284 | |||
| 285 | pub fn complete_albums(&self, prefix: &str) -> SqlResult<Vec<String>> { | ||
| 286 | let (artist, album_prefix) = match prefix.split_once('/') { | ||
| 287 | Some((a, ap)) => (a, ap), | ||
| 288 | None => return Ok(Vec::new()), | ||
| 289 | }; | ||
| 290 | let pattern = format!("{}%", album_prefix); | ||
| 291 | let mut stmt = self.conn.prepare( | ||
| 292 | "SELECT a.title FROM albums a | ||
| 293 | JOIN artists ar ON a.artist_id = ar.id | ||
| 294 | WHERE ar.name = ?1 AND a.title LIKE ?2 | ||
| 295 | ORDER BY a.title LIMIT 25", | ||
| 296 | )?; | ||
| 297 | let rows = stmt.query_map(rusqlite::params![artist, pattern], |row| { | ||
| 298 | row.get::<_, String>(0) | ||
| 299 | })?; | ||
| 300 | rows.collect() | ||
| 301 | } | ||
| 302 | |||
| 303 | pub fn complete_tracks(&self, prefix: &str) -> SqlResult<Vec<String>> { | ||
| 304 | let parts: Vec<&str> = prefix.splitn(3, '/').collect(); | ||
| 305 | if parts.len() < 3 { | ||
| 306 | return Ok(Vec::new()); | ||
| 307 | } | ||
| 308 | let (artist, album, track_prefix) = (parts[0], parts[1], parts[2]); | ||
| 309 | let pattern = format!("{}%", track_prefix); | ||
| 310 | let mut stmt = self.conn.prepare( | ||
| 311 | "SELECT t.title FROM tracks t | ||
| 312 | JOIN albums a ON t.album_id = a.id | ||
| 313 | JOIN artists ar ON a.artist_id = ar.id | ||
| 314 | WHERE ar.name = ?1 AND a.title = ?2 AND t.title LIKE ?3 | ||
| 315 | ORDER BY t.track_number, t.title LIMIT 25", | ||
| 316 | )?; | ||
| 317 | let rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| { | ||
| 318 | row.get::<_, String>(0) | ||
| 319 | })?; | ||
| 320 | rows.collect() | ||
| 321 | } | ||
| 322 | |||
| 323 | #[allow(dead_code)] | ||
| 324 | pub fn open_in_memory() -> SqlResult<Database> { | ||
| 325 | let conn = Connection::open_in_memory()?; | ||
| 326 | let db = Database { conn }; | ||
| 327 | db.init_schema()?; | ||
| 328 | Ok(db) | ||
| 329 | } | ||
| 330 | |||
| 331 | /// Find all tracks in an album by exact artist name and album title. | ||
| 332 | pub fn find_tracks_by_album(&self, artist: &str, album: &str) -> SqlResult<Vec<Track>> { | ||
| 333 | let mut stmt = self.conn.prepare( | ||
| 334 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 335 | FROM tracks t | ||
| 336 | JOIN albums a ON t.album_id = a.id | ||
| 337 | JOIN artists ar ON a.artist_id = ar.id | ||
| 338 | WHERE ar.name = ?1 AND a.title = ?2 | ||
| 339 | ORDER BY t.track_number", | ||
| 340 | )?; | ||
| 341 | let rows = stmt.query_map(rusqlite::params![artist, album], |row| { | ||
| 342 | Ok(Track { | ||
| 343 | id: row.get(0)?, | ||
| 344 | album_id: row.get(1)?, | ||
| 345 | title: row.get(2)?, | ||
| 346 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 347 | duration: row.get(4)?, | ||
| 348 | track_number: row.get(5)?, | ||
| 349 | artist_name: Some(row.get(6)?), | ||
| 350 | album_title: Some(row.get(7)?), | ||
| 351 | }) | ||
| 352 | })?; | ||
| 353 | rows.collect() | ||
| 354 | } | ||
| 355 | |||
| 356 | /// Find a single track by exact artist, exact album, and track title prefix. | ||
| 357 | /// Returns None if no match found. | ||
| 358 | pub fn find_track_by_parts( | ||
| 359 | &self, | ||
| 360 | artist: &str, | ||
| 361 | album: &str, | ||
| 362 | track_prefix: &str, | ||
| 363 | ) -> SqlResult<Option<Track>> { | ||
| 364 | let pattern = format!("{}%", track_prefix); | ||
| 365 | let mut stmt = self.conn.prepare( | ||
| 366 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 367 | FROM tracks t | ||
| 368 | JOIN albums a ON t.album_id = a.id | ||
| 369 | JOIN artists ar ON a.artist_id = ar.id | ||
| 370 | WHERE ar.name = ?1 AND a.title = ?2 AND t.title LIKE ?3 | ||
| 371 | ORDER BY t.track_number LIMIT 1", | ||
| 372 | )?; | ||
| 373 | let mut rows = stmt.query_map(rusqlite::params![artist, album, pattern], |row| { | ||
| 374 | Ok(Track { | ||
| 375 | id: row.get(0)?, | ||
| 376 | album_id: row.get(1)?, | ||
| 377 | title: row.get(2)?, | ||
| 378 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 379 | duration: row.get(4)?, | ||
| 380 | track_number: row.get(5)?, | ||
| 381 | artist_name: Some(row.get(6)?), | ||
| 382 | album_title: Some(row.get(7)?), | ||
| 383 | }) | ||
| 384 | })?; | ||
| 385 | match rows.next() { | ||
| 386 | Some(Ok(t)) => Ok(Some(t)), | ||
| 387 | Some(Err(e)) => Err(e), | ||
| 388 | None => Ok(None), | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | /// Resolve a hierarchical path like "Artist/Album" or "Artist/Album/Track" | ||
| 393 | /// to DB tracks. Returns None if the path isn't hierarchical (single part | ||
| 394 | /// or 4+ parts) — the caller should fall back to search_tracks(). | ||
| 395 | pub fn resolve_path(&self, path: &str) -> SqlResult<Option<Vec<Track>>> { | ||
| 396 | let path = path.strip_suffix('/').unwrap_or(path); | ||
| 397 | let parts: Vec<&str> = path.splitn(3, '/').collect(); | ||
| 398 | match parts.len() { | ||
| 399 | 2 => Ok(Some(self.find_tracks_by_album(parts[0], parts[1])?)), | ||
| 400 | 3 => match self.find_track_by_parts(parts[0], parts[1], parts[2])? { | ||
| 401 | Some(t) => Ok(Some(vec![t])), | ||
| 402 | None => Ok(Some(vec![])), | ||
| 403 | }, | ||
| 404 | _ => Ok(None), | ||
| 405 | } | ||
| 406 | } | ||
| 407 | |||
| 272 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { | 408 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { |
| 273 | let mut stmt = self.conn.prepare( | 409 | let mut stmt = self.conn.prepare( |
| 274 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | 410 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title |
