mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
Merge pull request #2 from olivr2346:pr/save-album-as-playlist
feat: add native support for saving albums and results as playlists
This commit is contained in:
commit
43289e1c42
4 changed files with 231 additions and 2 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
198
src/app.rs
198
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<Vec<Collection>, String>,
|
||||
},
|
||||
SavePlaylist {
|
||||
name: String,
|
||||
result: Result<Vec<Track>, 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<String>) {
|
||||
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<Vec<Track>, 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<String>) {
|
||||
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 <name> or :saveresults <name>",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ pub enum Command {
|
|||
Repeat(Option<RepeatMode>),
|
||||
Save(String),
|
||||
SaveForce(String),
|
||||
SaveAlbum(Option<String>),
|
||||
SaveResults(Option<String>),
|
||||
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<Option<Command>, 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()))))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/ui.rs
15
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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue