Compare commits

..
13 changed files with 85 additions and 2130 deletions

View file

@ -1,28 +1,16 @@
# Changelog
Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
All notable changes to Drift are documented in this file.
## Unreleased
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- MPRIS on linux. media keys, `playerctl` and waybar can now see and control drift (play/pause/next/prev/seek/stop), with title, artist, album and cover art. Shows up as `org.mpris.MediaPlayer2.drift`. If theres no dbus session it just doesnt start, nothing breaks.
- `:album <query>` to search albums, `:playlist <query>` to search youtube music playlists. Results show up in a picker, enter loads the tracks. (`:playlists` is still your own playlists tab.) Big playlists stop at ~300 tracks.
- `gb` / `:album` / `G` then `b` opens the album of the selected song. It finds it by album + artist name so it can guess wrong sometimes, and the song needs album info to begin with.
- `gs` / `:similar` / `:radio` loads ~50 songs similar to the selected one and starts playing them, like youtube music radio.
- Synced lyrics from LRCLIB. `L` toggles the side panel, `F` for fullscreen. Current line gets a progress fill, lines around it fade.
- Plain lyrics fallback when theres no synced version, scroll with `j`/`k`.
- Synced lyrics via LRCLIB. `L` or `:lyrics` toggles a side panel, `F` opens fullscreen.
- Current lyric line is highlighted with a progress fill; nearby lines fade out.
- Plain lyrics fallback (scroll with `j`/`k`) when no synced version exists.
### Changed
- Album tracks now get the artist/album/length filled in when youtube leaves them out.
- Tracks from a playlist get their album too, so `gb` works on them.
- New dependency on linux: `souvlaki` (zbus backend, pure rust, no system libs). `Cargo.lock` needs a regen, just run `cargo build` once. `--locked` builds, the flake and the AUR package need the new lock.
- Tab bar is pills now, inactive tabs have dimmed counts.
- Tab bar only reserves space for the spinner while busy, fits narrow terminals better.
### Fixed
- Playing from a playlist no longer replaces the queue. Playlists play as their own list, queue only has what you add, queued songs go first and then the playlist continues.
- Shuffle and repeat-all work for playlists and results now. repeat-all used to loop the queue instead of the list you were actualy playing.
- `P` / `:playall` doesnt wipe the queue anymore.
- Tab bar redesigned as pills with dimmed counts on inactive tabs.
- Tab bar only reserves spinner space while busy, so tabs fit on narrow terminals.

1261
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,9 +17,6 @@ ureq = { version = "2", features = ["json"] }
dirs = "5"
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
[profile.release]
lto = true
codegen-units = 1

View file

@ -29,8 +29,8 @@ Search, queue and play music with no account, no login and no browser.
- **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info
- **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history
- **Artist browsing** - press `ga` to see an artist's songs
- **Similar songs** - press `gs` to see similar songs
- **Audio visualizer** - a live visualizer while music plays
- **Session restore** - your queue and playback position come back on the next launch
- **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file
## Installation
@ -116,7 +116,6 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of
| `J` `K` | move a queue or playlist item down / up |
| `G` | song menu |
| `ga` | go to the artist |
| `gs` | see similar songs |
| `L` | toggle the lyrics panel |
| `F` | fullscreen lyrics |
| `/` `:` | search / command line |
@ -136,10 +135,6 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of
| `:save name` `:load name` `:append name` | work with playlists |
| `: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 |
| `:playlist query` | search YouTube Music playlists |
| `:similar` | radio: songs similar to the selected one (also `:radio`) |
| `:artist` | songs by the selected song's artist |
| `:lyrics` | toggle the lyrics panel |
## Lyrics

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792488032,
"narHash": "sha256-RkAXQ3+a5rHMiP97Er7AawR1kvEXX9tK1mhiWedOPTc=",
"lastModified": 1792116957,
"narHash": "sha256-EjZqHKObBfIOeHZnMRVV87HB3pPq/WBkmRJWyFxmZu8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e513db7b0a65def6f6e17bb55195dfe510871944",
"rev": "f20cd90e28fe0403873f473b29e42662cd86e8c4",
"type": "github"
},
"original": {

View file

@ -11,7 +11,7 @@
in {
default = pkgs.rustPlatform.buildRustPackage {
pname = "drift";
version = "1.3";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.makeWrapper ];

View file

@ -9,12 +9,11 @@ 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::{split_artists, Collection, RepeatMode, Track};
use crate::mpris::{Cmd, Mpris};
use crate::model::{split_artists, RepeatMode, Track};
use crate::player::{Player, PlayerEvent};
use crate::session::Session;
use crate::store::Store;
use crate::ytmusic::{self, Kind, Page};
use crate::ytmusic::{self, Page};
pub enum AppEvent {
Player(u64, PlayerEvent),
@ -31,12 +30,6 @@ pub enum AppEvent {
id: String,
result: Result<Option<Lyrics>, String>,
},
Collections {
id: u64,
title: String,
result: Result<Vec<Collection>, String>,
},
Media(Cmd),
}
const FIRST_PAGE: usize = 20;
@ -68,7 +61,6 @@ pub enum Mode {
Normal,
Command,
ArtistPick,
CollectionPick,
ConfirmDelete,
Help,
Menu,
@ -107,8 +99,6 @@ pub enum MenuAction {
PlayNext,
Queue,
Artist,
Similar,
Album,
PlaylistMenu,
PickPlaylist(String),
NewPlaylist,
@ -176,9 +166,6 @@ pub struct App {
pub artist_opts: Vec<String>,
pub info_track: Option<Track>,
pub artist_sel: usize,
pub picks: Vec<Collection>,
pub pick_sel: usize,
pub pick_title: String,
pub pending_delete: Option<String>,
pub help_scroll: usize,
pub lyrics: LyricsState,
@ -198,8 +185,6 @@ pub struct App {
pub repeat: RepeatMode,
pub accent: ratatui::style::Color,
played: HashSet<usize>,
radio_played: HashSet<usize>,
resume_radio: bool,
fail_streak: u8,
result_cursor: Option<usize>,
buffered: Vec<Track>,
@ -208,8 +193,6 @@ pub struct App {
results_gen: u64,
radio: Vec<Track>,
on_radio: bool,
similar_load: Option<u64>,
mpris: Option<Mpris>,
seek_step: f64,
volume_step: f64,
autoplay: bool,
@ -242,12 +225,6 @@ impl App {
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
| 1;
let mpris = {
let t = tx.clone();
Mpris::new(move |c| {
let _ = t.send(AppEvent::Media(c));
})
};
let mut app = App {
view: View::Results,
mode: Mode::Normal,
@ -271,9 +248,6 @@ impl App {
artist_opts: Vec::new(),
info_track: None,
artist_sel: 0,
picks: Vec::new(),
pick_sel: 0,
pick_title: String::new(),
pending_delete: None,
help_scroll: 0,
lyrics: LyricsState::Idle,
@ -292,8 +266,6 @@ impl App {
repeat: p.repeat,
accent: cfg.ui.accent,
played: HashSet::new(),
radio_played: HashSet::new(),
resume_radio: false,
fail_streak: 0,
result_cursor: None,
buffered: Vec::new(),
@ -302,8 +274,6 @@ impl App {
results_gen: 0,
radio: Vec::new(),
on_radio: false,
similar_load: None,
mpris,
seek_step: p.seek_step,
volume_step: p.volume_step,
autoplay: p.autoplay,
@ -345,7 +315,6 @@ impl App {
eprintln!("drift: could not save session: {e:#}");
}
self.player = None;
self.mpris = None;
}
fn save_session(&mut self, force: bool) -> Result<()> {
@ -389,7 +358,6 @@ impl App {
self.tick = self.tick.wrapping_add(1);
self.load_more();
self.sync_lyrics();
self.sync_mpris();
if self.tick.is_multiple_of(50) {
match self.save_session(false) {
Ok(()) => self.save_failed = false,
@ -492,7 +460,6 @@ impl App {
Mode::Help => self.key_help(key),
Mode::Command => self.key_command(key),
Mode::ArtistPick => self.key_artist_pick(key),
Mode::CollectionPick => self.key_collection_pick(key),
Mode::ConfirmDelete => self.key_confirm_delete(key),
Mode::Menu => self.key_menu(key),
Mode::Info => self.key_info(key),
@ -517,8 +484,6 @@ impl App {
('g', 'p') => self.set_view(View::Playlists),
('d', 'd') => self.delete_selected(),
('g', 'a') => self.go_to_artist(),
('g', 's') => self.go_similar(),
('g', 'b') => self.go_album(),
_ => {}
}
}
@ -661,10 +626,6 @@ impl App {
items.push(menu_item('a', "Add to queue", MenuAction::Queue));
}
items.push(menu_item('g', "Show artist", MenuAction::Artist));
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('l', "Add to playlist…", MenuAction::PlaylistMenu));
if in_queue {
items.push(menu_item('x', "Remove from queue", MenuAction::Remove));
@ -780,8 +741,6 @@ impl App {
}
MenuAction::Queue | MenuAction::AppendPlaylist => self.add_selected(),
MenuAction::Artist => self.go_to_artist(),
MenuAction::Similar => self.go_similar(),
MenuAction::Album => self.go_album(),
MenuAction::PlaylistMenu => {}
MenuAction::PickPlaylist(name) => self.add_to_playlist(&name),
MenuAction::NewPlaylist => self.prefill_command("addto "),
@ -1119,10 +1078,6 @@ impl App {
Command::AddTo(name) => self.add_to_playlist(&name),
Command::Import(url) => self.start_import(url),
Command::Artist => self.go_to_artist(),
Command::Similar => self.go_similar(),
Command::Album(Some(q)) => self.start_collection_search(q, Kind::Album),
Command::Album(None) => self.go_album(),
Command::PlaylistSearch(q) => self.start_collection_search(q, Kind::Playlist),
Command::Lyrics => self.lyrics_panel = !self.lyrics_panel,
Command::Help => self.open_help(),
}
@ -1227,127 +1182,6 @@ impl App {
});
}
fn go_album(&mut self) {
let Some(track) = self.artist_target() else {
return self.error("No song selected");
};
let Some(album) = track.album.clone() else {
return self.error("No album known for this song");
};
let artist = split_artists(&track.artist)
.into_iter()
.next()
.unwrap_or_default();
let busy = format!("Loading album \u{201c}{album}\u{201d}");
let title = format!("Album: {album}");
self.spawn_loader(title, busy, move || {
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).map(Page::from)
});
}
fn start_collection_search(&mut self, query: String, kind: Kind) {
let (what, title) = match kind {
Kind::Album => ("albums", format!("Albums: {query}")),
Kind::Playlist => ("playlists", format!("Playlists: {query}")),
};
self.search_id += 1;
let id = self.search_id;
self.busy = Some(format!("Searching {what} for \u{201c}{query}\u{201d}"));
let tx = self.tx.clone();
std::thread::spawn(move || {
let result = ytmusic::search_collections(&query, kind);
let _ = tx.send(AppEvent::Collections { id, title, result });
});
}
fn key_collection_pick(&mut self, key: KeyEvent) {
let len = self.picks.len();
if len == 0 {
self.mode = Mode::Normal;
return;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(),
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,
KeyCode::Char('G') | KeyCode::End => self.pick_sel = len - 1,
KeyCode::Char(c @ '1'..='9') => {
let i = c as usize - '1' as usize;
if i < len {
self.pick_sel = i;
self.confirm_collection();
}
}
KeyCode::Enter => self.confirm_collection(),
_ => {}
}
}
fn close_collection_pick(&mut self) {
self.mode = Mode::Normal;
self.picks.clear();
self.pick_sel = 0;
}
fn confirm_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 busy = format!("Loading \u{201c}{}\u{201d}", c.title);
let title = format!("{kind}: {}", c.title);
self.spawn_loader(title, busy, move || {
ytmusic::browse_tracks(&c.browse_id, &c.title, &c.artist).map(Page::from)
});
}
fn on_media(&mut self, cmd: Cmd) {
match cmd {
Cmd::Play => match self.play_state {
PlayState::Stopped => self.play_default(),
_ => self.set_pause(false),
},
Cmd::Pause => {
if self.play_state != PlayState::Stopped {
self.set_pause(true);
}
}
Cmd::Toggle => self.toggle_pause(),
Cmd::Stop => self.stop_playback(),
Cmd::Next => self.next(),
Cmd::Prev => self.prev(),
Cmd::SeekBy(d) => self.seek(Amount::Delta(d)),
Cmd::SeekTo(p) => self.seek(Amount::Abs(p)),
Cmd::Quit => self.should_quit = true,
}
}
fn sync_mpris(&mut self) {
if let Some(mut m) = self.mpris.take() {
m.update(self.now_track(), self.play_state, self.position);
self.mpris = Some(m);
}
}
fn go_similar(&mut self) {
let Some(track) = self.artist_target() else {
return self.error("No song selected");
};
let busy = format!("Finding songs like \u{201c}{}\u{201d}", track.title);
let title = format!("Similar to {}", track.title);
let id = track.id;
self.spawn_loader(title, busy, move || ytmusic::radio(&id).map(Page::from));
self.similar_load = Some(self.search_id);
}
fn start_search(&mut self, query: String) {
self.spawn_loader(
format!("Search: {query}"),
@ -1385,7 +1219,6 @@ impl App {
return;
}
self.busy = None;
let autoplay = self.similar_load.take() == Some(id);
match result {
Ok(page) if page.tracks.is_empty() => self.info("No results found."),
Ok(page) => {
@ -1403,9 +1236,6 @@ impl App {
self.states[View::Results.idx()].select(Some(0));
self.set_view(View::Results);
self.info(format!("{n} results — Enter plays, a queues, ? for help"));
if autoplay {
self.play_result(0);
}
}
Err(e) => self.error(e),
}
@ -1438,23 +1268,6 @@ impl App {
}
}
}
AppEvent::Collections { id, title, result } => {
if id != self.search_id {
return;
}
self.busy = None;
match result {
Ok(list) if list.is_empty() => self.info("Nothing found."),
Ok(list) => {
self.picks = list;
self.pick_sel = 0;
self.pick_title = title;
self.mode = Mode::CollectionPick;
}
Err(e) => self.error(e),
}
}
AppEvent::Media(cmd) => self.on_media(cmd),
AppEvent::Lyrics { id, result } => {
if self.lyrics_id.as_deref() != Some(id.as_str()) {
return;
@ -1643,7 +1456,6 @@ impl App {
} else {
self.current.unwrap_or(0)
};
self.resume_radio = false;
self.start_track(i.min(self.queue.len() - 1), true);
}
@ -1661,7 +1473,6 @@ impl App {
}
View::Queue => {
if i < self.queue.len() {
self.resume_radio = false;
self.play_index(i);
}
}
@ -1671,7 +1482,7 @@ impl App {
self.play_playlist_from(&name, i);
}
} else if let Some(name) = self.playlist_name(i) {
self.play_playlist_from(&name, 0);
self.load_playlist(&name, true);
}
}
}
@ -1682,7 +1493,6 @@ impl App {
return;
}
self.radio = self.results.clone();
self.radio_played.clear();
self.play_radio(i);
}
@ -1691,9 +1501,7 @@ impl App {
return;
};
self.on_radio = true;
self.resume_radio = true;
self.result_cursor = Some(i);
self.radio_played.insert(i);
self.resume = None;
self.play_state = PlayState::Loading;
self.position = 0.0;
@ -1796,12 +1604,11 @@ impl App {
}
let len = self.queue.len();
let wrap = self.repeat == RepeatMode::All && !(self.on_radio || self.resume_radio);
let next = if len == 0 {
None
} else if self.shuffle {
let mut pool: Vec<usize> = (0..len).filter(|i| !self.played.contains(i)).collect();
if pool.is_empty() && wrap {
if pool.is_empty() && self.repeat == RepeatMode::All {
self.played.clear();
pool = (0..len).filter(|i| Some(*i) != self.current).collect();
if pool.is_empty() {
@ -1817,7 +1624,7 @@ impl App {
} else {
match self.current {
Some(c) if c + 1 < len => Some(c + 1),
Some(_) if wrap => Some(0),
Some(_) if self.repeat == RepeatMode::All => Some(0),
Some(_) => None,
None => Some(0),
}
@ -1829,45 +1636,21 @@ impl App {
}
fn advance_radio(&mut self, auto: bool) {
let cursor = self
let next = self
.result_cursor
.filter(|_| self.on_radio || self.resume_radio || self.autoplay);
match cursor.and_then(|c| self.next_in_radio(c)) {
.filter(|_| self.on_radio || self.autoplay)
.map(|c| c + 1)
.filter(|&i| i < self.radio.len());
match next {
Some(i) => self.play_radio(i),
None if auto => {
self.stop_playback();
self.info("Finished playing");
self.info("Queue finished");
}
None => self.info("End of the list"),
None => self.info("End of queue"),
}
}
fn next_in_radio(&mut self, cursor: usize) -> Option<usize> {
let len = self.radio.len();
if !self.shuffle {
return match cursor + 1 {
n if n < len => Some(n),
_ if self.repeat == RepeatMode::All && len > 0 => Some(0),
_ => None,
};
}
let mut pool: Vec<usize> = (0..len)
.filter(|i| !self.radio_played.contains(i))
.collect();
if pool.is_empty() && self.repeat == RepeatMode::All {
self.radio_played.clear();
pool = (0..len).filter(|&i| i != cursor).collect();
if pool.is_empty() {
pool = vec![cursor];
}
}
if pool.is_empty() {
return None;
}
let k = self.rand_below(pool.len());
Some(pool[k])
}
fn seek(&mut self, amount: Amount) {
if !matches!(self.play_state, PlayState::Playing | PlayState::Paused) {
return self.error("Nothing is playing");
@ -1909,10 +1692,6 @@ impl App {
if let Some(c) = self.current {
self.played.insert(c);
}
self.radio_played.clear();
if let Some(c) = self.result_cursor {
self.radio_played.insert(c);
}
self.info(format!(
"Shuffle: {}",
if self.shuffle { "on" } else { "off" }
@ -1975,7 +1754,11 @@ impl App {
} else {
0
};
self.play_result(start.min(self.results.len() - 1));
self.queue = self.results.clone();
self.played.clear();
self.current = None;
self.result_cursor = None;
self.play_index(start.min(self.queue.len() - 1));
}
fn clear_queue(&mut self) {
@ -2126,7 +1909,6 @@ impl App {
self.played.clear();
self.current = None;
self.result_cursor = None;
self.resume_radio = false;
self.states[View::Queue.idx()].select(Some(0));
self.play_index(0);
self.info(format!(
@ -2161,9 +1943,12 @@ impl App {
return self.error(format!("Playlist \u{201c}{name}\u{201d} is empty"));
}
let i = i.min(tracks.len() - 1);
self.radio = tracks;
self.radio_played.clear();
self.play_radio(i);
self.queue = tracks;
self.played.clear();
self.current = None;
self.result_cursor = None;
self.states[View::Queue.idx()].select(Some(i));
self.play_index(i);
}
fn remove_playlist_track(&mut self, i: usize) {
@ -2295,61 +2080,6 @@ mod tests {
assert_eq!(app.results.len(), 25);
}
fn now_title(app: &App) -> Option<&str> {
app.now_track().map(|t| t.title.as_str())
}
fn check_playlist_playback(app: &mut App) {
app.queue.clear();
app.current = None;
app.played.clear();
app.store.playlists.insert(
"run".into(),
vec![
track("rrrrrrrrrr1", "R1"),
track("rrrrrrrrrr2", "R2"),
track("rrrrrrrrrr3", "R3"),
],
);
app.set_view(View::Playlists);
app.open = Some("run".into());
app.pl_state.select(Some(0));
press(app, KeyCode::Enter);
assert!(app.queue.is_empty());
assert_eq!(now_title(app), Some("R1"));
app.enqueue(track("qqqqqqqqqq1", "Q1"));
app.advance(true);
assert_eq!(now_title(app), Some("Q1"));
app.advance(true);
assert_eq!(now_title(app), Some("R2"));
app.advance(true);
assert_eq!(now_title(app), Some("R3"));
assert_eq!(app.queue.len(), 1);
app.repeat = RepeatMode::All;
app.advance(true);
assert_eq!(now_title(app), Some("R1"));
app.repeat = RepeatMode::Off;
app.open = None;
let i = app.store.playlists.keys().position(|k| k == "run").unwrap();
app.activate_at(View::Playlists, i);
assert_eq!(now_title(app), Some("R1"));
assert_eq!(app.queue.len(), 1);
app.shuffle = true;
app.advance(true);
let first = app.result_cursor.unwrap();
app.advance(true);
let second = app.result_cursor.unwrap();
assert!(first != 0 && second != 0 && first != second);
app.advance(true);
assert_eq!(app.result_cursor, Some(second));
app.shuffle = false;
}
#[test]
fn playlist_drill_in() {
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id()));
@ -2444,8 +2174,6 @@ mod tests {
app.run_command("remove");
assert_eq!(app.queue.len(), 1);
check_playlist_playback(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -36,9 +36,6 @@ pub enum Command {
AddTo(String),
Import(String),
Artist,
Similar,
Album(Option<String>),
PlaylistSearch(String),
Lyrics,
Help,
}
@ -72,9 +69,6 @@ pub const COMMAND_NAMES: &[&str] = &[
"addto",
"import",
"artist",
"similar",
"album",
"playlist",
"lyrics",
"help",
];
@ -183,14 +177,6 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
Import(arg.to_string())
}
"artist" | "ar" => Artist,
"similar" | "radio" | "sim" => Similar,
"album" | "albums" | "al" => Album((!arg.is_empty()).then(|| arg.to_string())),
"playlist" => {
if arg.is_empty() {
return Err("usage: :playlist \"query\"".into());
}
PlaylistSearch(arg.to_string())
}
"lyrics" | "ly" => Lyrics,
"help" | "h" => Help,
other => return Err(format!("unknown command: :{other} (try :help)")),
@ -256,19 +242,6 @@ mod tests {
assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
assert_eq!(parse("lyrics"), Ok(Some(Command::Lyrics)));
assert_eq!(parse("similar"), Ok(Some(Command::Similar)));
assert_eq!(parse(":radio"), Ok(Some(Command::Similar)));
assert_eq!(parse("album"), Ok(Some(Command::Album(None))));
assert_eq!(
parse("album daft punk"),
Ok(Some(Command::Album(Some("daft punk".into()))))
);
assert_eq!(
parse("playlist lofi"),
Ok(Some(Command::PlaylistSearch("lofi".into())))
);
assert!(parse("playlist").is_err());
assert_eq!(parse("playlists"), Ok(Some(Command::ShowPlaylists)));
}
#[test]

View file

@ -3,7 +3,6 @@ mod command;
mod config;
mod lyrics;
mod model;
mod mpris;
mod player;
mod session;
mod store;
@ -129,12 +128,6 @@ fn run(initial_query: Option<String>) -> Result<()> {
}
let mut terminal = ratatui::try_init()?;
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().name() == Some("main") {
hook(info);
}
}));
let result = event_loop(&mut terminal, &mut app, &rx);
ratatui::restore();
app.shutdown();

View file

@ -21,14 +21,6 @@ impl Track {
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Collection {
pub browse_id: String,
pub title: String,
pub subtitle: String,
pub artist: String,
}
pub fn split_artists(credit: &str) -> Vec<String> {
const MARK: char = '\u{1}';
let mut s = credit.to_string();

View file

@ -1,162 +0,0 @@
/* MPRIS (media keys, playerctl, waybar...) on Linux; a no-op elsewhere. */
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Cmd {
Play,
Pause,
Toggle,
Stop,
Next,
Prev,
SeekBy(f64),
SeekTo(f64),
Quit,
}
#[cfg(target_os = "linux")]
mod imp {
use std::time::Duration;
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition,
PlatformConfig, SeekDirection,
};
use super::Cmd;
use crate::app::PlayState;
use crate::model::Track;
pub struct Mpris {
controls: MediaControls,
track: Option<String>,
state: PlayState,
secs: u64,
}
impl Mpris {
/* None when there is no session bus. */
pub fn new(send: impl Fn(Cmd) + Send + 'static) -> Option<Mpris> {
let name = format!("drift.instance{}", std::process::id());
let config = PlatformConfig {
dbus_name: &name,
display_name: "drift",
hwnd: None,
};
let mut controls = MediaControls::new(config).ok()?;
controls
.attach(move |ev| {
if let Some(c) = convert(ev) {
send(c);
}
})
.ok()?;
Some(Mpris {
controls,
track: None,
state: PlayState::Stopped,
secs: 0,
})
}
pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) {
let id = track.map(|t| t.id.as_str());
if id != self.track.as_deref() {
self.track = id.map(str::to_string);
let cover = track.map(|t| format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", t.id));
let meta = match track {
Some(t) => MediaMetadata {
title: Some(t.title.as_str()),
album: t.album.as_deref(),
artist: Some(t.artist.as_str()),
cover_url: cover.as_deref(),
duration: t.duration.map(|d| Duration::from_secs(u64::from(d))),
},
None => MediaMetadata::default(),
};
let _ = self.controls.set_metadata(meta);
self.secs = u64::MAX;
}
let pos = if position.is_finite() { position.max(0.0) } else { 0.0 };
let secs = pos as u64;
if state == self.state && secs == self.secs {
return;
}
self.state = state;
self.secs = secs;
let progress = Some(MediaPosition(Duration::from_secs_f64(pos)));
let _ = self.controls.set_playback(match state {
PlayState::Playing | PlayState::Loading => MediaPlayback::Playing { progress },
PlayState::Paused => MediaPlayback::Paused { progress },
PlayState::Stopped => MediaPlayback::Stopped,
});
}
}
fn dir(d: SeekDirection, secs: f64) -> f64 {
if d == SeekDirection::Backward {
-secs
} else {
secs
}
}
pub(super) fn convert(ev: MediaControlEvent) -> Option<Cmd> {
Some(match ev {
MediaControlEvent::Play => Cmd::Play,
MediaControlEvent::Pause => Cmd::Pause,
MediaControlEvent::Toggle => Cmd::Toggle,
MediaControlEvent::Next => Cmd::Next,
MediaControlEvent::Previous => Cmd::Prev,
MediaControlEvent::Stop => Cmd::Stop,
MediaControlEvent::Seek(d) => Cmd::SeekBy(dir(d, 5.0)),
MediaControlEvent::SeekBy(d, by) => Cmd::SeekBy(dir(d, by.as_secs_f64())),
MediaControlEvent::SetPosition(MediaPosition(p)) => Cmd::SeekTo(p.as_secs_f64()),
MediaControlEvent::Quit => Cmd::Quit,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_events() {
assert_eq!(convert(MediaControlEvent::Previous), Some(Cmd::Prev));
assert_eq!(
convert(MediaControlEvent::SeekBy(
SeekDirection::Backward,
Duration::from_secs(10)
)),
Some(Cmd::SeekBy(-10.0))
);
assert_eq!(
convert(MediaControlEvent::SetPosition(MediaPosition(
Duration::from_secs(90)
))),
Some(Cmd::SeekTo(90.0))
);
assert_eq!(convert(MediaControlEvent::Raise), None);
}
}
}
#[cfg(not(target_os = "linux"))]
mod imp {
use super::Cmd;
use crate::app::PlayState;
use crate::model::Track;
pub struct Mpris;
impl Mpris {
pub fn new(_send: impl Fn(Cmd) + Send + 'static) -> Option<Mpris> {
None
}
pub fn update(&mut self, _track: Option<&Track>, _state: PlayState, _position: f64) {}
}
}
pub use imp::Mpris;

View file

@ -69,9 +69,6 @@ pub fn draw(f: &mut Frame, app: &mut App) {
if app.mode == Mode::ArtistPick {
draw_artist_picker(f, app, area);
}
if app.mode == Mode::CollectionPick {
draw_collection_picker(f, app, area);
}
if app.mode == Mode::Info {
draw_info(f, app, area);
}
@ -804,9 +801,6 @@ const CMD_INFO: &[(&str, &str)] = &[
("addto", "add the selected track to a playlist"),
("import", "import a YouTube / YT Music playlist URL"),
("artist", "songs by the selected artist"),
("similar", "radio: songs similar to the selected one"),
("album", "open the selected song's album, or :album <query> to search"),
("playlist", "search YouTube Music playlists"),
("lyrics", "show or hide the lyrics panel"),
("help", "show key bindings"),
];
@ -1081,19 +1075,10 @@ const HELP: &[(&str, &str)] = &[
"ga or :artist",
"songs by the artist of the selected song; a popup asks which one when there are several",
),
(
"gb or :album",
"open the album of the selected song; :album <query> searches albums",
),
(":playlist <query>", "search YouTube Music playlists (pick one, then ⏎)"),
(
"gs or :similar",
"radio: load songs similar to the selected one and start playing them",
),
("SONG MENU ( G )", "#"),
("G", "menu for the highlighted song or playlist"),
("j k ⏎", "move / choose (the yellow letter is a shortcut)"),
("p n a g s b l", "play · next · queue · artist · similar · album · playlist"),
("p n a g l", "play · play next · add to queue · artist · playlist"),
("x i", "remove · song info"),
("Esc q G", "close (Esc goes back from a sub-menu)"),
("QUEUE", "#"),
@ -1131,8 +1116,6 @@ const HELP: &[(&str, &str)] = &[
(":addto n", "add the selected track to playlist n"),
(":import URL", "load a YouTube / YT Music playlist"),
(":artist", "same as ga"),
(":similar", "same as gs (also :radio)"),
(":album [q]", "same as gb, or search albums"),
(":lyrics", "same as L"),
(":quit (or q, Ctrl-c)", ""),
];
@ -1236,55 +1219,6 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) {
);
}
fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) {
let n = app.picks.len();
let w = area.width.min(78);
let h = (n as u16).saturating_add(3).min(area.height);
let visible = h.saturating_sub(3) as usize;
let start = if app.pick_sel < visible {
0
} else {
app.pick_sel + 1 - visible
};
let popup = Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
};
let inner = w.saturating_sub(2) as usize;
let mut lines: Vec<Line> = app
.picks
.iter()
.enumerate()
.skip(start)
.take(visible)
.map(|(i, c)| {
let mut text = format!(" {:>2} {}", i + 1, c.title);
if !c.subtitle.is_empty() {
text.push_str(&format!(" · {}", c.subtitle));
}
let text: String = text.chars().take(inner).collect();
if i == app.pick_sel {
let pad = inner.saturating_sub(text.chars().count());
Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent))
} else {
Line::raw(text)
}
})
.collect();
lines.push(Line::styled(" Enter select · j k move · Esc cancel", dim()));
f.render_widget(Clear, popup);
f.render_widget(
Paragraph::new(lines).block(
Block::bordered()
.title(format!(" {} ", app.pick_title))
.border_style(Style::new().fg(Color::Yellow)),
),
popup,
);
}
fn draw_help(f: &mut Frame, app: &mut App, area: Rect) {
let w = area.width.min(80);
let lines = help_lines(w.saturating_sub(2) as usize, app.accent);

View file

@ -4,26 +4,15 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result};
use serde_json::{json, Value};
use crate::model::{is_valid_id, parse_clock, Collection, Track};
use crate::model::{is_valid_id, parse_clock, Track};
const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
const BROWSE_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false";
const NEXT_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/next?prettyPrint=false";
const ALBUMS_FILTER: &str = "EgWKAQIYAWoMEA4QChADEAQQCRAF";
const PLAYLISTS_FILTER: &str = "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D";
const RADIO_TARGET: usize = 50;
const RADIO_PAGES: usize = 4;
const SONGS_FILTER: &str = "EgWKAQIIAWoMEA4QChADEAQQCRAF";
const ARTISTS_FILTER: &str = "EgWKAQIgAWoMEA4QChADEAQQCRAF";
const UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
const FALLBACK_COUNT: usize = 25;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
Album,
Playlist,
}
pub struct Page {
pub tracks: Vec<Track>,
pub next: Option<String>,
@ -60,91 +49,6 @@ pub fn search_more(token: &str) -> Result<Page, String> {
.map_err(|e| format!("{e:#}"))
}
pub fn search_collections(query: &str, kind: Kind) -> Result<Vec<Collection>, String> {
let params = match kind {
Kind::Album => ALBUMS_FILTER,
Kind::Playlist => PLAYLISTS_FILTER,
};
post(ENDPOINT, json!({ "query": query, "params": params }))
.map(|v| parse_collections(&v, kind))
.map_err(|e| format!("{e:#}"))
}
/* browse_id: MPREb_... for an album, VL... for a playlist */
pub fn browse_tracks(browse_id: &str, album: &str, artist: &str) -> Result<Vec<Track>, String> {
let playlist = browse_id.starts_with("VL");
let v = post(BROWSE_ENDPOINT, json!({ "browseId": browse_id })).map_err(|e| format!("{e:#}"))?;
let mut tracks = parse_browse(&v, playlist);
let mut next = if playlist { find_continuation(&v) } else { None };
let mut pages = 0;
while let Some(tok) = next.take() {
if pages >= 5 || tracks.len() >= 300 {
break;
}
pages += 1;
let url = format!("{BROWSE_ENDPOINT}&ctoken={tok}&continuation={tok}");
let Ok(v) = post(&url, json!({})) else { break };
let more = parse_browse(&v, true);
if more.is_empty() {
break;
}
let known: std::collections::HashSet<String> =
tracks.iter().map(|t| t.id.clone()).collect();
tracks.extend(more.into_iter().filter(|t| !known.contains(&t.id)));
next = find_continuation(&v);
}
if tracks.is_empty() {
return Err("nothing playable found".into());
}
if !playlist {
for t in &mut tracks {
if !album.is_empty() && t.album.is_none() {
t.album = Some(album.to_string());
}
if !artist.is_empty() && t.artist == "Unknown artist" {
t.artist = artist.to_string();
}
}
}
Ok(tracks)
}
pub fn radio(seed: &str) -> Result<Vec<Track>, String> {
fetch_radio(seed).map_err(|e| format!("{e:#}"))
}
fn fetch_radio(seed: &str) -> Result<Vec<Track>> {
let body = json!({
"videoId": seed,
"playlistId": format!("RDAMVM{seed}"),
"params": "wAEB",
"isAudioOnly": true,
"enablePersistentPlaylistPanel": true,
"tunerSettingValue": "AUTOMIX_SETTING_NORMAL",
});
let mut url = NEXT_ENDPOINT.to_string();
let mut tracks: Vec<Track> = Vec::new();
for _ in 0..RADIO_PAGES {
let v = post(&url, body.clone())?;
let before = tracks.len();
for t in parse_radio_response(&v) {
if !tracks.iter().any(|x| x.id == t.id) {
tracks.push(t);
}
}
match find_continuation(&v) {
Some(tok) if tracks.len() < RADIO_TARGET && tracks.len() > before => {
url = format!("{NEXT_ENDPOINT}&ctoken={tok}&continuation={tok}");
}
_ => break,
}
}
if tracks.is_empty() {
bail!("no similar songs found");
}
Ok(tracks)
}
pub fn import(url: &str) -> Result<Vec<Track>, String> {
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("import expects a full http(s) URL".into());
@ -215,7 +119,6 @@ fn find_continuation(v: &Value) -> Option<String> {
Value::Object(map) => {
let token = map
.get("nextContinuationData")
.or_else(|| map.get("nextRadioContinuationData"))
.and_then(|c| c.get("continuation"))
.or_else(|| {
map.get("continuationCommand")
@ -317,15 +220,11 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
}
fn column_segments(item: &Value, col: usize) -> Vec<String> {
run_segments(
item.get("flexColumns")
.and_then(|c| c.get(col))
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
.and_then(Value::as_array),
)
}
fn run_segments(runs: Option<&Vec<Value>>) -> Vec<String> {
let runs = item
.get("flexColumns")
.and_then(|c| c.get(col))
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
.and_then(Value::as_array);
let mut segments = Vec::new();
let mut cur = String::new();
for run in runs.into_iter().flatten() {
@ -350,13 +249,6 @@ fn looks_like_duration(s: &str) -> bool {
s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':')
}
fn fixed_duration(item: &Value) -> Option<u32> {
let s = item
.pointer("/fixedColumns/0/musicResponsiveListItemFixedColumnRenderer/text/runs/0/text")?
.as_str()?;
parse_clock(s).map(|s| s as u32)
}
fn parse_item(item: &Value) -> Option<Track> {
let id = find_str(item, "videoId")?;
if !is_valid_id(id) {
@ -379,7 +271,6 @@ fn parse_item(item: &Value) -> Option<Track> {
}
_ => None,
};
let duration = duration.or_else(|| fixed_duration(item));
let artist = meta
.first()
.cloned()
@ -398,63 +289,6 @@ fn parse_item(item: &Value) -> Option<Track> {
})
}
fn parse_browse(v: &Value, playlist: bool) -> Vec<Track> {
let mut items = Vec::new();
collect_items(v, &mut items);
let mut seen = std::collections::HashSet::new();
items
.into_iter()
.filter_map(|item| {
let mut t = parse_item(item)?;
if playlist && t.album.is_none() {
t.album = column_segments(item, 2).into_iter().next();
}
Some(t)
})
.filter(|t| seen.insert(t.id.clone()))
.collect()
}
pub fn parse_collections(v: &Value, kind: Kind) -> Vec<Collection> {
let mut items = Vec::new();
collect_items(v, &mut items);
let mut seen = std::collections::HashSet::new();
items
.into_iter()
.filter_map(|item| {
let id = item
.pointer("/navigationEndpoint/browseEndpoint/browseId")?
.as_str()?;
let ok = match kind {
Kind::Album => id.starts_with("MPRE"),
Kind::Playlist => id.starts_with("VL"),
};
if !ok {
return None;
}
let title = column_segments(item, 0).into_iter().next()?;
let mut meta = column_segments(item, 1);
if matches!(
meta.first().map(String::as_str),
Some("Album" | "EP" | "Single" | "Playlist")
) {
meta.remove(0);
}
let artist = match kind {
Kind::Album => meta.first().cloned().unwrap_or_default(),
Kind::Playlist => String::new(),
};
Some(Collection {
browse_id: id.to_string(),
title,
subtitle: meta.join(" · "),
artist,
})
})
.filter(|c| seen.insert(c.browse_id.clone()))
.collect()
}
pub fn parse_search_response(v: &Value) -> Vec<Track> {
let mut items = Vec::new();
collect_items(v, &mut items);
@ -466,76 +300,6 @@ pub fn parse_search_response(v: &Value) -> Vec<Track> {
.collect()
}
fn collect_panel<'a>(v: &'a Value, out: &mut Vec<&'a Value>) {
match v {
Value::Object(map) => {
if let Some(w) = map.get("playlistPanelVideoWrapperRenderer") {
out.extend(w.pointer("/primaryRenderer/playlistPanelVideoRenderer"));
} else if let Some(item) = map.get("playlistPanelVideoRenderer") {
out.push(item);
} else {
map.values().for_each(|c| collect_panel(c, out));
}
}
Value::Array(arr) => arr.iter().for_each(|c| collect_panel(c, out)),
_ => {}
}
}
fn parse_panel_item(item: &Value) -> Option<Track> {
let id = item.get("videoId")?.as_str()?;
if !is_valid_id(id) {
return None;
}
let title: String = item
.pointer("/title/runs")?
.as_array()?
.iter()
.filter_map(|r| r.get("text")?.as_str())
.collect();
let title = title.trim().to_string();
if title.is_empty() {
return None;
}
let meta = run_segments(item.pointer("/longBylineText/runs").and_then(Value::as_array));
let artist = meta
.first()
.cloned()
.unwrap_or_else(|| "Unknown artist".into());
let album = meta
.get(1)
.filter(|a| {
!a.contains(" views")
&& !a.contains(" plays")
&& !(a.len() == 4 && a.bytes().all(|b| b.is_ascii_digit()))
})
.cloned();
let duration = item
.pointer("/lengthText/runs/0/text")
.or_else(|| item.pointer("/lengthText/simpleText"))
.and_then(Value::as_str)
.and_then(parse_clock)
.map(|s| s as u32);
Some(Track {
id: id.to_string(),
title,
artist,
album,
duration,
})
}
pub fn parse_radio_response(v: &Value) -> Vec<Track> {
let mut items = Vec::new();
collect_panel(v, &mut items);
let mut seen = std::collections::HashSet::new();
items
.into_iter()
.filter_map(parse_panel_item)
.filter(|t| seen.insert(t.id.clone()))
.collect()
}
fn ytdlp_search(query: &str) -> Result<Vec<Track>> {
ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}"))
}
@ -699,76 +463,6 @@ not json
assert_eq!(find_continuation(&json!({"a": [1, 2]})), None);
}
#[test]
fn parses_radio_panel() {
let item = |id: &str, title: &str, by: Value, len: &str| {
json!({"playlistPanelVideoRenderer": {
"videoId": id,
"title": {"runs": [{"text": title}]},
"longBylineText": {"runs": by},
"lengthText": {"runs": [{"text": len}]},
}})
};
let by = json!([{"text": "A"}, {"text": " & "}, {"text": "B"},
{"text": " • "}, {"text": "Album"}, {"text": " • "}, {"text": "2019"}]);
let resp = json!({"contents": [
item("abcdefghijk", "One", by, "3:21"),
{"playlistPanelVideoWrapperRenderer": {
"primaryRenderer": item("bcdefghijkl", "Two", json!([{"text": "C"}]), "1:05"),
"counterpart": [item("zzzzzzzzzzz", "Video", json!([{"text": "C"}]), "1:05")],
}},
item("abcdefghijk", "dup", json!([{"text": "A"}]), "3:21"),
]});
let t = parse_radio_response(&resp);
assert_eq!(t.len(), 2);
assert_eq!(t[0].artist, "A & B");
assert_eq!(t[0].album.as_deref(), Some("Album"));
assert_eq!(t[0].duration, Some(201));
assert_eq!(t[1].id, "bcdefghijkl");
assert_eq!(t[1].album, None);
}
#[test]
fn parses_album_and_playlist_results() {
let row = |id: &str, title: &str, runs: Value| {
json!({"musicResponsiveListItemRenderer": {
"navigationEndpoint": {"browseEndpoint": {"browseId": id}},
"flexColumns": [
{"musicResponsiveListItemFlexColumnRenderer": run(title)},
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": runs}}},
]
}})
};
let resp = json!([
row("MPREb_abc", "Discovery", json!([{"text": "Album"}, {"text": " • "},
{"text": "Daft Punk"}, {"text": " • "}, {"text": "2001"}])),
row("VLPL123", "Mix", json!([{"text": "Playlist"}, {"text": " • "}, {"text": "Someone"}])),
row("UCartist", "Artist", json!([{"text": "Artist"}])),
]);
let a = parse_collections(&resp, Kind::Album);
assert_eq!(a.len(), 1);
assert_eq!(a[0].title, "Discovery");
assert_eq!(a[0].artist, "Daft Punk");
assert_eq!(a[0].subtitle, "Daft Punk · 2001");
let p = parse_collections(&resp, Kind::Playlist);
assert_eq!(p.len(), 1);
assert_eq!(p[0].browse_id, "VLPL123");
assert_eq!(p[0].artist, "");
}
#[test]
fn parses_browse_tracks() {
let resp = json!({"contents": [{"musicResponsiveListItemRenderer": {
"playlistItemData": {"videoId": "abcdefghijk"},
"flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run("One More Time")}],
"fixedColumns": [{"musicResponsiveListItemFixedColumnRenderer": run("5:20")}],
}}]});
let t = parse_browse(&resp, false);
assert_eq!(t.len(), 1);
assert_eq!(t[0].duration, Some(320));
assert_eq!(t[0].artist, "Unknown artist");
}
#[test]
fn date_math() {
assert_eq!(civil_from_days(0), (1970, 1, 1));