aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs36
-rw-r--r--src/daemon.rs183
-rw-r--r--src/database.rs136
3 files changed, 331 insertions, 24 deletions
diff --git a/src/cli.rs b/src/cli.rs
index f5fdd2c..5a53eeb 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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)]
47impl Database { 47impl 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