diff options
| author | salmonik <raisersoft@proton.me> | 2026-05-27 20:26:56 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-05-27 20:26:56 +0200 |
| commit | 09c78a14cb7dc83c7711b6e58025d43272e9eaf7 (patch) | |
| tree | e5eec691695c3beff320a2a2a9c2d60c2524ec55 | |
| parent | add licensing (diff) | |
| download | rsplayer-09c78a14cb7dc83c7711b6e58025d43272e9eaf7.tar.gz rsplayer-09c78a14cb7dc83c7711b6e58025d43272e9eaf7.zip | |
play [track] [-a/--album] option
if supplied when a track is played, the rest of the album will be queued
after the track
| -rw-r--r-- | src/cli.rs | 15 | ||||
| -rw-r--r-- | src/daemon.rs | 141 | ||||
| -rw-r--r-- | src/database.rs | 70 |
3 files changed, 224 insertions, 2 deletions
| @@ -35,6 +35,9 @@ pub enum Commands { | |||
| 35 | /// Add to queue after current track (don't skip) | 35 | /// Add to queue after current track (don't skip) |
| 36 | #[arg(short = 'n', long = "next")] | 36 | #[arg(short = 'n', long = "next")] |
| 37 | next: bool, | 37 | next: bool, |
| 38 | /// Queue rest of album after this track (requires a track query) | ||
| 39 | #[arg(short = 'a', long = "album")] | ||
| 40 | album: bool, | ||
| 38 | #[arg(value_name = "QUERY")] | 41 | #[arg(value_name = "QUERY")] |
| 39 | query: Option<String>, | 42 | query: Option<String>, |
| 40 | }, | 43 | }, |
| @@ -129,7 +132,11 @@ impl Cli { | |||
| 129 | crate::daemon::restart_daemon()?; | 132 | crate::daemon::restart_daemon()?; |
| 130 | } | 133 | } |
| 131 | Commands::Play { | 134 | Commands::Play { |
| 132 | now, next, query, .. | 135 | now, |
| 136 | next, | ||
| 137 | album, | ||
| 138 | query, | ||
| 139 | .. | ||
| 133 | } => { | 140 | } => { |
| 134 | if let Some(q) = query { | 141 | if let Some(q) = query { |
| 135 | let pos = if *now { | 142 | let pos = if *now { |
| @@ -139,7 +146,11 @@ impl Cli { | |||
| 139 | } else { | 146 | } else { |
| 140 | "last" | 147 | "last" |
| 141 | }; | 148 | }; |
| 142 | crate::daemon::send_command(format!("play {} {}", pos, q))?; | 149 | if *album { |
| 150 | crate::daemon::send_command(format!("play-album {} {}", pos, q))?; | ||
| 151 | } else { | ||
| 152 | crate::daemon::send_command(format!("play {} {}", pos, q))?; | ||
| 153 | } | ||
| 143 | } else { | 154 | } else { |
| 144 | crate::daemon::send_command("play last".to_string())?; | 155 | crate::daemon::send_command("play last".to_string())?; |
| 145 | } | 156 | } |
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( | |||
| 681 | "Error accessing queue\n".to_string() | 681 | "Error accessing queue\n".to_string() |
| 682 | } | 682 | } |
| 683 | } | 683 | } |
| 684 | _ if cmd.starts_with("play-album ") => { | ||
| 685 | let rest = &cmd[11..].trim(); | ||
| 686 | let (position, query_str): (&str, &str) = if let Some((pos, p)) = rest.split_once(' ') { | ||
| 687 | if pos == "now" || pos == "next" || pos == "last" { | ||
| 688 | (pos, p) | ||
| 689 | } else { | ||
| 690 | ("last", rest) | ||
| 691 | } | ||
| 692 | } else { | ||
| 693 | ("last", rest) | ||
| 694 | }; | ||
| 695 | |||
| 696 | if query_str.is_empty() { | ||
| 697 | return "Usage: play-album [now|next|last] <path_or_query>\n".to_string(); | ||
| 698 | } | ||
| 699 | |||
| 700 | let path = std::path::PathBuf::from(query_str); | ||
| 701 | |||
| 702 | // Resolve the source track path and its album continuation tracks. | ||
| 703 | // If the argument is an existing file, extract metadata from the path | ||
| 704 | // and look up the album in the DB. Otherwise search the DB by query. | ||
| 705 | let (track_path, album_paths, display_name): (PathBuf, Vec<PathBuf>, String) = | ||
| 706 | if path.is_file() { | ||
| 707 | let album_paths: Vec<PathBuf> = match Config::load() { | ||
| 708 | Ok(config) => match crate::database::Database::open(&config) { | ||
| 709 | Ok(db) => db | ||
| 710 | .find_album_tracks_after_path(&path) | ||
| 711 | .unwrap_or_default() | ||
| 712 | .into_iter() | ||
| 713 | .map(|t| t.path) | ||
| 714 | .collect(), | ||
| 715 | Err(_) => Vec::new(), | ||
| 716 | }, | ||
| 717 | Err(_) => Vec::new(), | ||
| 718 | }; | ||
| 719 | let name = path | ||
| 720 | .file_name() | ||
| 721 | .and_then(|n| n.to_str()) | ||
| 722 | .unwrap_or("unknown") | ||
| 723 | .to_string(); | ||
| 724 | (path, album_paths, name) | ||
| 725 | } else { | ||
| 726 | match Config::load() { | ||
| 727 | Ok(config) => match crate::database::Database::open(&config) { | ||
| 728 | Ok(db) => match db.search_tracks(query_str) { | ||
| 729 | Ok(tracks) if !tracks.is_empty() => { | ||
| 730 | let db_track = &tracks[0]; | ||
| 731 | let album_tracks = db | ||
| 732 | .get_album_tracks_after( | ||
| 733 | db_track.album_id, | ||
| 734 | db_track.track_number, | ||
| 735 | &db_track.path, | ||
| 736 | ) | ||
| 737 | .unwrap_or_default(); | ||
| 738 | let display = format!( | ||
| 739 | "{} - {}", | ||
| 740 | db_track.artist_name.as_deref().unwrap_or("unknown"), | ||
| 741 | db_track.title, | ||
| 742 | ); | ||
| 743 | let paths: Vec<PathBuf> = | ||
| 744 | album_tracks.into_iter().map(|t| t.path).collect(); | ||
| 745 | (db_track.path.clone(), paths, display) | ||
| 746 | } | ||
| 747 | Ok(_) => { | ||
| 748 | return format!("No tracks found matching '{}'\n", query_str); | ||
| 749 | } | ||
| 750 | Err(e) => return format!("Database error: {}\n", e), | ||
| 751 | }, | ||
| 752 | Err(e) => return format!("Database error: {}\n", e), | ||
| 753 | }, | ||
| 754 | Err(e) => return format!("Config error: {}\n", e), | ||
| 755 | } | ||
| 756 | }; | ||
| 757 | |||
| 758 | if let Ok(mut q) = queue.lock() { | ||
| 759 | let was_empty = q.is_empty(); | ||
| 760 | match position { | ||
| 761 | "now" => { | ||
| 762 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 763 | try_scrobble_current(scrobbler, &mut s); | ||
| 764 | } | ||
| 765 | for t in album_paths.iter().rev() { | ||
| 766 | q.insert_after_current(t.clone()); | ||
| 767 | } | ||
| 768 | q.insert_after_current(track_path.clone()); | ||
| 769 | if !was_empty { | ||
| 770 | let new_pos = q.position() + 1; | ||
| 771 | q.set_position(new_pos); | ||
| 772 | } | ||
| 773 | if let Some(track) = q.track_at(q.position()) { | ||
| 774 | audio.play(track.path.clone()); | ||
| 775 | send_now_playing(scrobbler, track); | ||
| 776 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 777 | s.start(track.clone()); | ||
| 778 | } | ||
| 779 | let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); | ||
| 780 | notification::notify_track(track, fmt.as_deref()); | ||
| 781 | } | ||
| 782 | format!( | ||
| 783 | "Playing {} with {} album track(s)\n", | ||
| 784 | display_name, | ||
| 785 | album_paths.len() | ||
| 786 | ) | ||
| 787 | } | ||
| 788 | "next" => { | ||
| 789 | for t in album_paths.iter().rev() { | ||
| 790 | q.insert_after_current(t.clone()); | ||
| 791 | } | ||
| 792 | q.insert_after_current(track_path.clone()); | ||
| 793 | format!( | ||
| 794 | "Queued next: {} with {} album track(s)\n", | ||
| 795 | display_name, | ||
| 796 | album_paths.len() | ||
| 797 | ) | ||
| 798 | } | ||
| 799 | _ => { | ||
| 800 | q.add_track(track_path.clone()); | ||
| 801 | for t in &album_paths { | ||
| 802 | q.add_track(t.clone()); | ||
| 803 | } | ||
| 804 | if was_empty | ||
| 805 | && !audio.is_playing() | ||
| 806 | && let Some(track) = q.current() | ||
| 807 | { | ||
| 808 | audio.play(track.path.clone()); | ||
| 809 | send_now_playing(scrobbler, track); | ||
| 810 | if let Ok(mut s) = scrobble_state.lock() { | ||
| 811 | s.start(track.clone()); | ||
| 812 | } | ||
| 813 | } | ||
| 814 | format!( | ||
| 815 | "Queued: {} with {} album track(s)\n", | ||
| 816 | display_name, | ||
| 817 | album_paths.len() | ||
| 818 | ) | ||
| 819 | } | ||
| 820 | } | ||
| 821 | } else { | ||
| 822 | "Error accessing queue\n".to_string() | ||
| 823 | } | ||
| 824 | } | ||
| 684 | _ if cmd.starts_with("play ") => { | 825 | _ if cmd.starts_with("play ") => { |
| 685 | let rest = &cmd[5..].trim(); | 826 | let rest = &cmd[5..].trim(); |
| 686 | let (position, path_str): (&str, &str) = if let Some((pos, p)) = rest.split_once(' ') { | 827 | 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 { | |||
| 199 | Ok(()) | 199 | Ok(()) |
| 200 | } | 200 | } |
| 201 | 201 | ||
| 202 | pub fn get_album_tracks_after( | ||
| 203 | &self, | ||
| 204 | album_id: i64, | ||
| 205 | after_track_number: Option<i32>, | ||
| 206 | exclude_path: &Path, | ||
| 207 | ) -> SqlResult<Vec<Track>> { | ||
| 208 | let exclude = exclude_path.to_string_lossy(); | ||
| 209 | let mut stmt = self.conn.prepare( | ||
| 210 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | ||
| 211 | FROM tracks t | ||
| 212 | JOIN albums a ON t.album_id = a.id | ||
| 213 | JOIN artists ar ON a.artist_id = ar.id | ||
| 214 | WHERE t.album_id = ?1 | ||
| 215 | AND t.path != ?3 | ||
| 216 | AND (?2 IS NULL OR t.track_number > ?2) | ||
| 217 | ORDER BY t.track_number", | ||
| 218 | )?; | ||
| 219 | |||
| 220 | let rows = stmt.query_map( | ||
| 221 | rusqlite::params![album_id, after_track_number, exclude.as_ref()], | ||
| 222 | |row| { | ||
| 223 | Ok(Track { | ||
| 224 | id: row.get(0)?, | ||
| 225 | album_id: row.get(1)?, | ||
| 226 | title: row.get(2)?, | ||
| 227 | path: PathBuf::from(row.get::<_, String>(3)?), | ||
| 228 | duration: row.get(4)?, | ||
| 229 | track_number: row.get(5)?, | ||
| 230 | artist_name: Some(row.get(6)?), | ||
| 231 | album_title: Some(row.get(7)?), | ||
| 232 | }) | ||
| 233 | }, | ||
| 234 | )?; | ||
| 235 | |||
| 236 | rows.collect() | ||
| 237 | } | ||
| 238 | |||
| 239 | /// Find album tracks that come after the given file's position in its album. | ||
| 240 | /// Uses directory-based metadata extraction (matching how the DB stores data). | ||
| 241 | /// Returns empty vec if the file's album isn't in the DB. | ||
| 242 | pub fn find_album_tracks_after_path(&self, path: &Path) -> SqlResult<Vec<Track>> { | ||
| 243 | // Canonicalize so relative paths match absolute paths stored in the DB | ||
| 244 | let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); | ||
| 245 | let (ref artist, ref album, _, _, _) = extract_metadata(&path); | ||
| 246 | |||
| 247 | let album_id = match self.conn.query_row( | ||
| 248 | "SELECT al.id FROM albums al | ||
| 249 | JOIN artists ar ON al.artist_id = ar.id | ||
| 250 | WHERE ar.name = ? AND al.title = ?", | ||
| 251 | rusqlite::params![artist, album], | ||
| 252 | |row| row.get::<_, i64>(0), | ||
| 253 | ) { | ||
| 254 | Ok(id) => id, | ||
| 255 | Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(Vec::new()), | ||
| 256 | Err(e) => return Err(e), | ||
| 257 | }; | ||
| 258 | |||
| 259 | let path_str = path.to_string_lossy(); | ||
| 260 | let track_number: Option<i32> = self | ||
| 261 | .conn | ||
| 262 | .query_row( | ||
| 263 | "SELECT track_number FROM tracks WHERE path = ?", | ||
| 264 | rusqlite::params![path_str.as_ref()], | ||
| 265 | |row| row.get(0), | ||
| 266 | ) | ||
| 267 | .unwrap_or(None); | ||
| 268 | |||
| 269 | self.get_album_tracks_after(album_id, track_number, &path) | ||
| 270 | } | ||
| 271 | |||
| 202 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { | 272 | pub fn search_tracks(&self, query: &str) -> SqlResult<Vec<Track>> { |
| 203 | let mut stmt = self.conn.prepare( | 273 | let mut stmt = self.conn.prepare( |
| 204 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title | 274 | "SELECT t.id, t.album_id, t.title, t.path, t.duration, t.track_number, ar.name, a.title |
