From 09c78a14cb7dc83c7711b6e58025d43272e9eaf7 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 27 May 2026 20:26:56 +0200 Subject: play [track] [-a/--album] option if supplied when a track is played, the rest of the album will be queued after the track --- src/cli.rs | 15 +++++- src/daemon.rs | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/database.rs | 70 ++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/cli.rs b/src/cli.rs index 05cd61f..f5fdd2c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -35,6 +35,9 @@ pub enum Commands { /// Add to queue after current track (don't skip) #[arg(short = 'n', long = "next")] next: bool, + /// Queue rest of album after this track (requires a track query) + #[arg(short = 'a', long = "album")] + album: bool, #[arg(value_name = "QUERY")] query: Option, }, @@ -129,7 +132,11 @@ impl Cli { crate::daemon::restart_daemon()?; } Commands::Play { - now, next, query, .. + now, + next, + album, + query, + .. } => { if let Some(q) = query { let pos = if *now { @@ -139,7 +146,11 @@ impl Cli { } else { "last" }; - crate::daemon::send_command(format!("play {} {}", pos, q))?; + if *album { + crate::daemon::send_command(format!("play-album {} {}", pos, q))?; + } else { + crate::daemon::send_command(format!("play {} {}", pos, q))?; + } } else { crate::daemon::send_command("play last".to_string())?; } diff --git a/src/daemon.rs b/src/daemon.rs index 63510ea..20f4e7e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -681,6 +681,147 @@ fn process_command( "Error accessing queue\n".to_string() } } + _ if cmd.starts_with("play-album ") => { + let rest = &cmd[11..].trim(); + let (position, query_str): (&str, &str) = if let Some((pos, p)) = rest.split_once(' ') { + if pos == "now" || pos == "next" || pos == "last" { + (pos, p) + } else { + ("last", rest) + } + } else { + ("last", rest) + }; + + if query_str.is_empty() { + return "Usage: play-album [now|next|last] \n".to_string(); + } + + let path = std::path::PathBuf::from(query_str); + + // Resolve the source track path and its album continuation tracks. + // If the argument is an existing file, extract metadata from the path + // and look up the album in the DB. Otherwise search the DB by query. + let (track_path, album_paths, display_name): (PathBuf, Vec, String) = + if path.is_file() { + let album_paths: Vec = match Config::load() { + Ok(config) => match crate::database::Database::open(&config) { + Ok(db) => db + .find_album_tracks_after_path(&path) + .unwrap_or_default() + .into_iter() + .map(|t| t.path) + .collect(), + Err(_) => Vec::new(), + }, + Err(_) => Vec::new(), + }; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + (path, album_paths, name) + } else { + match Config::load() { + Ok(config) => match crate::database::Database::open(&config) { + Ok(db) => match db.search_tracks(query_str) { + Ok(tracks) if !tracks.is_empty() => { + let db_track = &tracks[0]; + let album_tracks = db + .get_album_tracks_after( + db_track.album_id, + db_track.track_number, + &db_track.path, + ) + .unwrap_or_default(); + let display = format!( + "{} - {}", + db_track.artist_name.as_deref().unwrap_or("unknown"), + db_track.title, + ); + let paths: Vec = + album_tracks.into_iter().map(|t| t.path).collect(); + (db_track.path.clone(), paths, display) + } + Ok(_) => { + return format!("No tracks found matching '{}'\n", query_str); + } + Err(e) => return format!("Database error: {}\n", e), + }, + Err(e) => return format!("Database error: {}\n", e), + }, + Err(e) => return format!("Config error: {}\n", e), + } + }; + + if let Ok(mut q) = queue.lock() { + let was_empty = q.is_empty(); + match position { + "now" => { + if let Ok(mut s) = scrobble_state.lock() { + try_scrobble_current(scrobbler, &mut s); + } + for t in album_paths.iter().rev() { + q.insert_after_current(t.clone()); + } + q.insert_after_current(track_path.clone()); + if !was_empty { + let new_pos = q.position() + 1; + q.set_position(new_pos); + } + if let Some(track) = q.track_at(q.position()) { + audio.play(track.path.clone()); + send_now_playing(scrobbler, track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } + let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); + notification::notify_track(track, fmt.as_deref()); + } + format!( + "Playing {} with {} album track(s)\n", + display_name, + album_paths.len() + ) + } + "next" => { + for t in album_paths.iter().rev() { + q.insert_after_current(t.clone()); + } + q.insert_after_current(track_path.clone()); + format!( + "Queued next: {} with {} album track(s)\n", + display_name, + album_paths.len() + ) + } + _ => { + q.add_track(track_path.clone()); + for t in &album_paths { + q.add_track(t.clone()); + } + if was_empty + && !audio.is_playing() + && let Some(track) = q.current() + { + audio.play(track.path.clone()); + send_now_playing(scrobbler, track); + if let Ok(mut s) = scrobble_state.lock() { + s.start(track.clone()); + } + } + format!( + "Queued: {} with {} album track(s)\n", + display_name, + album_paths.len() + ) + } + } + } else { + "Error accessing queue\n".to_string() + } + } _ if cmd.starts_with("play ") => { let rest = &cmd[5..].trim(); let (position, path_str): (&str, &str) = if let Some((pos, p)) = rest.split_once(' ') { diff --git a/src/database.rs b/src/database.rs index cd839b1..b80ad4a 100644 --- a/src/database.rs +++ b/src/database.rs @@ -199,6 +199,76 @@ impl Database { Ok(()) } + pub fn get_album_tracks_after( + &self, + album_id: i64, + after_track_number: Option, + exclude_path: &Path, + ) -> SqlResult> { + let exclude = exclude_path.to_string_lossy(); + let mut stmt = self.conn.prepare( + "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title + FROM tracks t + JOIN albums a ON t.album_id = a.id + JOIN artists ar ON a.artist_id = ar.id + WHERE t.album_id = ?1 + AND t.path != ?3 + AND (?2 IS NULL OR t.track_number > ?2) + ORDER BY t.track_number", + )?; + + let rows = stmt.query_map( + rusqlite::params![album_id, after_track_number, exclude.as_ref()], + |row| { + Ok(Track { + id: row.get(0)?, + album_id: row.get(1)?, + title: row.get(2)?, + path: PathBuf::from(row.get::<_, String>(3)?), + duration: row.get(4)?, + track_number: row.get(5)?, + artist_name: Some(row.get(6)?), + album_title: Some(row.get(7)?), + }) + }, + )?; + + rows.collect() + } + + /// Find album tracks that come after the given file's position in its album. + /// Uses directory-based metadata extraction (matching how the DB stores data). + /// Returns empty vec if the file's album isn't in the DB. + pub fn find_album_tracks_after_path(&self, path: &Path) -> SqlResult> { + // Canonicalize so relative paths match absolute paths stored in the DB + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let (ref artist, ref album, _, _, _) = extract_metadata(&path); + + let album_id = match self.conn.query_row( + "SELECT al.id FROM albums al + JOIN artists ar ON al.artist_id = ar.id + WHERE ar.name = ? AND al.title = ?", + rusqlite::params![artist, album], + |row| row.get::<_, i64>(0), + ) { + Ok(id) => id, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + + let path_str = path.to_string_lossy(); + let track_number: Option = self + .conn + .query_row( + "SELECT track_number FROM tracks WHERE path = ?", + rusqlite::params![path_str.as_ref()], + |row| row.get(0), + ) + .unwrap_or(None); + + self.get_album_tracks_after(album_id, track_number, &path) + } + pub fn search_tracks(&self, query: &str) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title -- cgit v1.2.3