diff --git a/README.md b/README.md index d958b1d..37199a3 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ makepkg -si ### Install script -On Arch, Debian/Ubuntu, Fedora and Void, the install script installs the dependencies and builds Drift with cargo: +On Arch, Debian/Ubuntu, Fedora, openSUSE, Gentoo & Void the install script installs the dependencies and builds Drift with cargo: ```bash git clone https://github.com/socatlolmeow/drift.git @@ -134,6 +134,7 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of | `:shuffle` `:repeat [off\|all\|one]` | playback modes | | `:add [N]` `:addall` `:playall` | queue results | | `:save name` `:load name` `:append name` | work with playlists | +| `:savealbum [name]` `:saveresults [name]` | save album or results directly as a playlist | | `:addto name` `:delpl name` | add a song to / delete a playlist | | `:import URL` | import a YouTube / YouTube Music playlist | | `:album [query]` | open the selected song's album, or search albums | @@ -167,6 +168,9 @@ popular_first = false [ui] accent = "cyan" # a color name or a hex value like "#FF8800" + +[discord] +client_id = "" # change this for Discord RPC to work in Drift ``` Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session. diff --git a/src/app.rs b/src/app.rs index 952b95f..1f49e89 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use ratatui::widgets::TableState; use crate::command::{self, Amount, Command, COMMAND_NAMES}; use crate::config::{self, Config}; use crate::lyrics::{self, Lyrics}; -use crate::model::{Collection, RepeatMode, Track}; +use crate::model::{split_artists, Collection, RepeatMode, Track}; use crate::discord::Discord; use crate::mpris::{Cmd, Mpris}; use crate::player::{self, Player, PlayerEvent}; @@ -37,6 +37,10 @@ pub enum AppEvent { title: String, result: Result, String>, }, + SavePlaylist { + name: String, + result: Result, String>, + }, Media(Cmd), Audio { id: String, @@ -114,6 +118,9 @@ pub enum MenuAction { Artist, Similar, Album, + SaveTrackAlbum, + SaveAlbum, + SaveResults, PlaylistMenu, PickPlaylist(String), NewPlaylist, @@ -543,6 +550,7 @@ impl App { ('g', 'a') => self.go_to_artist(), ('g', 's') => self.go_similar(), ('g', 'b') => self.go_album(), + ('g', 'B') => self.save_album(None), _ => {} } } @@ -688,6 +696,7 @@ impl App { items.push(menu_item('s', "Similar songs", MenuAction::Similar)); if track.album.is_some() { items.push(menu_item('b', "Show album", MenuAction::Album)); + items.push(menu_item('B', "Save album to playlist", MenuAction::SaveTrackAlbum)); } items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu)); if in_queue { @@ -699,6 +708,11 @@ impl App { if self.view == View::Results { items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll)); items.push(menu_item('P', "Play all results", MenuAction::PlayAll)); + if self.results_title.starts_with("Album: ") { + items.push(menu_item('S', "Save album as playlist…", MenuAction::SaveAlbum)); + } else { + items.push(menu_item('S', "Save results as playlist…", MenuAction::SaveResults)); + } } if in_queue { items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue)); @@ -806,6 +820,21 @@ impl App { MenuAction::Artist => self.go_to_artist(), MenuAction::Similar => self.go_similar(), MenuAction::Album => self.go_album(), + MenuAction::SaveTrackAlbum => { + if let Some(t) = track { + let album_title = t.album.clone().unwrap_or_else(|| "Album".to_string()); + self.save_album_background(t, album_title); + } + } + MenuAction::SaveAlbum => { + let default_name = self.results_title.strip_prefix("Album: ").unwrap_or("").trim(); + if !default_name.is_empty() { + self.prefill_command(&format!("savealbum \"{default_name}\"")); + } else { + self.prefill_command("savealbum "); + } + } + MenuAction::SaveResults => self.prefill_command("saveresults "), MenuAction::PlaylistMenu => {} MenuAction::PickPlaylist(name) => self.add_to_playlist(&name), MenuAction::NewPlaylist => self.prefill_command("addto "), @@ -1134,6 +1163,8 @@ impl App { } Command::Save(name) => self.save_playlist(&name, false), Command::SaveForce(name) => self.save_playlist(&name, true), + Command::SaveAlbum(name) => self.save_album(name), + Command::SaveResults(name) => self.save_results_as_playlist(name), Command::Load(name) => match self.store.resolve(&name) { Some(n) => self.load_playlist(&n, true), None => self.error(format!("no playlist named '{name}'")), @@ -1283,6 +1314,56 @@ impl App { }); } + fn save_album(&mut self, name: Option) { + if self.view == View::Results + && self.results_title.starts_with("Album: ") + && !self.results.is_empty() + { + return self.save_results_as_playlist(name); + } + + if let Some(track) = self.artist_target() { + if let Some(album) = track.album.clone() { + let target_name = name.unwrap_or(album); + return self.save_album_background(track, target_name); + } + } + + if self.view == View::Results && !self.results.is_empty() { + return self.save_results_as_playlist(name); + } + + self.error("No album to save — open an album or select a song with an album"); + } + + fn save_album_background(&mut self, track: Track, target_name: String) { + let Some(album) = track.album else { + return self.error("No album known for this song"); + }; + let artist = split_artists(&track.artist) + .into_iter() + .next() + .unwrap_or_default(); + self.busy = Some(format!("Saving album \u{201c}{album}\u{201d}")); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let result = (|| -> Result, String> { + let found = ytmusic::search_collections(&format!("{album} {artist}"), Kind::Album)?; + let want = album.to_lowercase(); + let pick = found + .iter() + .find(|c| c.title.to_lowercase() == want) + .or(found.first()) + .ok_or_else(|| "album not found".to_string())?; + ytmusic::browse_tracks(&pick.browse_id, &pick.title, &pick.artist) + })(); + let _ = tx.send(AppEvent::SavePlaylist { + name: target_name, + result, + }); + }); + } + fn start_collection_search(&mut self, query: String, kind: Kind) { let (what, title) = match kind { Kind::Album => ("albums", format!("Albums: {query}")), @@ -1306,6 +1387,7 @@ impl App { } match key.code { KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(), + KeyCode::Char('s') | KeyCode::Char('S') => self.save_picked_collection(), KeyCode::Char('j') | KeyCode::Down => self.pick_sel = (self.pick_sel + 1).min(len - 1), KeyCode::Char('k') | KeyCode::Up => self.pick_sel = self.pick_sel.saturating_sub(1), KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0, @@ -1340,6 +1422,23 @@ impl App { }); } + fn save_picked_collection(&mut self) { + let c = self.picks.get(self.pick_sel).cloned(); + self.close_collection_pick(); + let Some(c) = c else { return }; + let kind = if c.browse_id.starts_with("VL") { "Playlist" } else { "Album" }; + let target_name = c.title.clone(); + self.busy = Some(format!("Saving {kind} \u{201c}{target_name}\u{201d}")); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let result = ytmusic::browse_tracks(&c.browse_id, &c.title, &c.artist); + let _ = tx.send(AppEvent::SavePlaylist { + name: target_name, + result, + }); + }); + } + fn on_media(&mut self, cmd: Cmd) { match cmd { Cmd::Play => match self.play_state { @@ -1492,6 +1591,29 @@ impl App { } } AppEvent::Media(cmd) => self.on_media(cmd), + AppEvent::SavePlaylist { name, result } => { + self.busy = None; + match result { + Ok(tracks) if tracks.is_empty() => { + self.error(format!("Album \u{201c}{name}\u{201d} contains no tracks")); + } + Ok(tracks) => { + let n = tracks.len(); + let key = self.store.resolve_exact(&name).unwrap_or(name); + self.store.playlists.insert(key.clone(), tracks); + if self.persist() { + self.info(format!( + "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" + )); + let sel = self.selected(); + self.set_selected(sel); + } + } + Err(e) => { + self.error(format!("Could not save album \u{201c}{name}\u{201d}: {e}")) + } + } + } AppEvent::Lyrics { id, result } => { if self.lyrics_id.as_deref() != Some(id.as_str()) { return; @@ -2179,6 +2301,40 @@ impl App { } } + fn save_results_as_playlist(&mut self, name: Option) { + if self.results.is_empty() { + return self.error("No results to save — search or open an album first"); + } + let target_name = match name { + Some(n) if !n.trim().is_empty() => n.trim().to_string(), + _ => { + if let Some(rest) = self.results_title.strip_prefix("Album: ") { + rest.trim().to_string() + } else if let Some(rest) = self.results_title.strip_prefix("Playlist: ") { + rest.trim().to_string() + } else if let Some(album) = self.results.iter().find_map(|t| t.album.as_deref()) { + album.to_string() + } else { + return self.error( + "No album name found; usage: :savealbum or :saveresults ", + ); + } + } + }; + let n = self.results.len(); + let key = self.store.resolve_exact(&target_name).unwrap_or(target_name); + self.store + .playlists + .insert(key.clone(), self.results.clone()); + if self.persist() { + self.info(format!( + "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" + )); + let sel = self.selected(); + self.set_selected(sel); + } + } + fn add_to_playlist(&mut self, name: &str) { let i = self.selected(); let track = match self.view { @@ -2543,6 +2699,46 @@ mod tests { check_playlist_playback(&mut app); + // Test savealbum and saveresults + let mut t1 = track("song1111111", "Dreams"); + t1.album = Some("Rumours".into()); + let mut t2 = track("song2222222", "Go Your Own Way"); + t2.album = Some("Rumours".into()); + app.results = vec![t1.clone(), t2.clone()]; + app.results_title = "Album: Rumours".into(); + app.view = View::Results; + + // :savealbum with no args uses album name from results_title + app.run_command("savealbum"); + assert_eq!(app.store.playlists.get("Rumours").map(Vec::len), Some(2)); + + // :savealbum with custom name + app.run_command(r#"savealbum "Fleetwood Mac - Rumours""#); + assert_eq!( + app.store + .playlists + .get("Fleetwood Mac - Rumours") + .map(Vec::len), + Some(2) + ); + + // :saveresults with custom name + app.run_command(r#"saveresults "Classic Hits""#); + assert_eq!( + app.store.playlists.get("Classic Hits").map(Vec::len), + Some(2) + ); + + // Test AppEvent::SavePlaylist background event + app.on_event(AppEvent::SavePlaylist { + name: "Background Album".into(), + result: Ok(vec![track("bg111111111", "BG Track")]), + }); + assert_eq!( + app.store.playlists.get("Background Album").map(Vec::len), + Some(1) + ); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/command.rs b/src/command.rs index ccb116d..3f6ada2 100644 --- a/src/command.rs +++ b/src/command.rs @@ -31,6 +31,8 @@ pub enum Command { Repeat(Option), Save(String), SaveForce(String), + SaveAlbum(Option), + SaveResults(Option), Load(String), Append(String), DeletePlaylist(String), @@ -67,6 +69,8 @@ pub const COMMAND_NAMES: &[&str] = &[ "shuffle", "repeat", "save", + "savealbum", + "saveresults", "load", "append", "delpl", @@ -174,6 +178,8 @@ pub fn parse(line: &str) -> Result, String> { }, "save" | "w" => Save(name_arg(arg, "save")?), "save!" | "w!" => SaveForce(name_arg(arg, "save!")?), + "savealbum" | "sa" => SaveAlbum((!arg.is_empty()).then(|| arg.to_string())), + "saveresults" | "sr" => SaveResults((!arg.is_empty()).then(|| arg.to_string())), "load" | "e" => Load(name_arg(arg, "load")?), "append" => Append(name_arg(arg, "append")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), @@ -284,5 +290,18 @@ mod tests { parse("save! my mix"), Ok(Some(Command::SaveForce("my mix".into()))) ); + assert_eq!(parse("savealbum"), Ok(Some(Command::SaveAlbum(None)))); + assert_eq!( + parse(r#"savealbum "Abbey Road""#), + Ok(Some(Command::SaveAlbum(Some("Abbey Road".into())))) + ); + assert_eq!( + parse("saveresults"), + Ok(Some(Command::SaveResults(None))) + ); + assert_eq!( + parse(r#"saveresults "My Hits""#), + Ok(Some(Command::SaveResults(Some("My Hits".into())))) + ); } } diff --git a/src/ui.rs b/src/ui.rs index 6894886..e49af1a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -806,6 +806,8 @@ const CMD_INFO: &[(&str, &str)] = &[ ("shuffle", "toggle shuffle"), ("repeat", "repeat · off | all | one"), ("save", "save the queue as a playlist"), + ("savealbum", "save album as a playlist · savealbum [name]"), + ("saveresults", "save results as a playlist · saveresults [name]"), ("load", "load a playlist into the queue"), ("append", "append a playlist to the queue"), ("delpl", "delete a playlist"), @@ -1132,6 +1134,14 @@ const HELP: &[(&str, &str)] = &[ ":save n :load n", "save the queue as a playlist / load one (:save! n replaces an existing one)", ), + ( + ":savealbum [n]", + "save album or current album results as a playlist", + ), + ( + ":saveresults [n]", + "save current results as a playlist", + ), ( ":append n :delpl n", "append a playlist to the queue / delete it", @@ -1281,7 +1291,10 @@ fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) { } }) .collect(); - lines.push(Line::styled(" Enter select · j k move · Esc cancel", dim())); + lines.push(Line::styled( + " Enter select · s save playlist · j k move · Esc cancel", + dim(), + )); f.render_widget(Clear, popup); f.render_widget( Paragraph::new(lines).block(