aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-27 20:26:56 +0200
committersalmonik <raisersoft@proton.me>2026-05-27 20:26:56 +0200
commit09c78a14cb7dc83c7711b6e58025d43272e9eaf7 (patch)
treee5eec691695c3beff320a2a2a9c2d60c2524ec55
parentadd licensing (diff)
downloadrsplayer-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.rs15
-rw-r--r--src/daemon.rs141
-rw-r--r--src/database.rs70
3 files changed, 224 insertions, 2 deletions
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 {
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