mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a7f101d1e7 |
|||
|
b3fbcfdfd6 |
|||
| 468ba7ee96 | |||
| da5b76ab06 |
13 changed files with 2129 additions and 84 deletions
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,16 +1,28 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to Drift are documented in this file.
|
||||
Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
### Changed
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
|
|||
1271
Cargo.lock
generated
1271
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,9 @@ 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
|
||||
|
|
|
|||
|
|
@ -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,6 +116,7 @@ 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 |
|
||||
|
|
@ -135,6 +136,10 @@ 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
6
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792116957,
|
||||
"narHash": "sha256-EjZqHKObBfIOeHZnMRVV87HB3pPq/WBkmRJWyFxmZu8=",
|
||||
"lastModified": 1792488032,
|
||||
"narHash": "sha256-RkAXQ3+a5rHMiP97Er7AawR1kvEXX9tK1mhiWedOPTc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f20cd90e28fe0403873f473b29e42662cd86e8c4",
|
||||
"rev": "e513db7b0a65def6f6e17bb55195dfe510871944",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
in {
|
||||
default = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "drift";
|
||||
version = "0.1.0";
|
||||
version = "1.3";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
|
|
|
|||
318
src/app.rs
318
src/app.rs
|
|
@ -9,11 +9,12 @@ 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, RepeatMode, Track};
|
||||
use crate::model::{split_artists, Collection, RepeatMode, Track};
|
||||
use crate::mpris::{Cmd, Mpris};
|
||||
use crate::player::{Player, PlayerEvent};
|
||||
use crate::session::Session;
|
||||
use crate::store::Store;
|
||||
use crate::ytmusic::{self, Page};
|
||||
use crate::ytmusic::{self, Kind, Page};
|
||||
|
||||
pub enum AppEvent {
|
||||
Player(u64, PlayerEvent),
|
||||
|
|
@ -30,6 +31,12 @@ 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;
|
||||
|
|
@ -61,6 +68,7 @@ pub enum Mode {
|
|||
Normal,
|
||||
Command,
|
||||
ArtistPick,
|
||||
CollectionPick,
|
||||
ConfirmDelete,
|
||||
Help,
|
||||
Menu,
|
||||
|
|
@ -99,6 +107,8 @@ pub enum MenuAction {
|
|||
PlayNext,
|
||||
Queue,
|
||||
Artist,
|
||||
Similar,
|
||||
Album,
|
||||
PlaylistMenu,
|
||||
PickPlaylist(String),
|
||||
NewPlaylist,
|
||||
|
|
@ -166,6 +176,9 @@ 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,
|
||||
|
|
@ -185,6 +198,8 @@ 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>,
|
||||
|
|
@ -193,6 +208,8 @@ 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,
|
||||
|
|
@ -225,6 +242,12 @@ 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,
|
||||
|
|
@ -248,6 +271,9 @@ 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,
|
||||
|
|
@ -266,6 +292,8 @@ 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(),
|
||||
|
|
@ -274,6 +302,8 @@ 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,
|
||||
|
|
@ -315,6 +345,7 @@ impl App {
|
|||
eprintln!("drift: could not save session: {e:#}");
|
||||
}
|
||||
self.player = None;
|
||||
self.mpris = None;
|
||||
}
|
||||
|
||||
fn save_session(&mut self, force: bool) -> Result<()> {
|
||||
|
|
@ -358,6 +389,7 @@ 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,
|
||||
|
|
@ -460,6 +492,7 @@ 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),
|
||||
|
|
@ -484,6 +517,8 @@ 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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -626,6 +661,10 @@ 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));
|
||||
|
|
@ -741,6 +780,8 @@ 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 "),
|
||||
|
|
@ -1078,6 +1119,10 @@ 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(),
|
||||
}
|
||||
|
|
@ -1182,6 +1227,127 @@ 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}"),
|
||||
|
|
@ -1219,6 +1385,7 @@ 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) => {
|
||||
|
|
@ -1236,6 +1403,9 @@ 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),
|
||||
}
|
||||
|
|
@ -1268,6 +1438,23 @@ 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;
|
||||
|
|
@ -1456,6 +1643,7 @@ impl App {
|
|||
} else {
|
||||
self.current.unwrap_or(0)
|
||||
};
|
||||
self.resume_radio = false;
|
||||
self.start_track(i.min(self.queue.len() - 1), true);
|
||||
}
|
||||
|
||||
|
|
@ -1473,6 +1661,7 @@ impl App {
|
|||
}
|
||||
View::Queue => {
|
||||
if i < self.queue.len() {
|
||||
self.resume_radio = false;
|
||||
self.play_index(i);
|
||||
}
|
||||
}
|
||||
|
|
@ -1482,7 +1671,7 @@ impl App {
|
|||
self.play_playlist_from(&name, i);
|
||||
}
|
||||
} else if let Some(name) = self.playlist_name(i) {
|
||||
self.load_playlist(&name, true);
|
||||
self.play_playlist_from(&name, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1493,6 +1682,7 @@ impl App {
|
|||
return;
|
||||
}
|
||||
self.radio = self.results.clone();
|
||||
self.radio_played.clear();
|
||||
self.play_radio(i);
|
||||
}
|
||||
|
||||
|
|
@ -1501,7 +1691,9 @@ 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;
|
||||
|
|
@ -1604,11 +1796,12 @@ 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() && self.repeat == RepeatMode::All {
|
||||
if pool.is_empty() && wrap {
|
||||
self.played.clear();
|
||||
pool = (0..len).filter(|i| Some(*i) != self.current).collect();
|
||||
if pool.is_empty() {
|
||||
|
|
@ -1624,7 +1817,7 @@ impl App {
|
|||
} else {
|
||||
match self.current {
|
||||
Some(c) if c + 1 < len => Some(c + 1),
|
||||
Some(_) if self.repeat == RepeatMode::All => Some(0),
|
||||
Some(_) if wrap => Some(0),
|
||||
Some(_) => None,
|
||||
None => Some(0),
|
||||
}
|
||||
|
|
@ -1636,21 +1829,45 @@ impl App {
|
|||
}
|
||||
|
||||
fn advance_radio(&mut self, auto: bool) {
|
||||
let next = self
|
||||
let cursor = self
|
||||
.result_cursor
|
||||
.filter(|_| self.on_radio || self.autoplay)
|
||||
.map(|c| c + 1)
|
||||
.filter(|&i| i < self.radio.len());
|
||||
match next {
|
||||
.filter(|_| self.on_radio || self.resume_radio || self.autoplay);
|
||||
match cursor.and_then(|c| self.next_in_radio(c)) {
|
||||
Some(i) => self.play_radio(i),
|
||||
None if auto => {
|
||||
self.stop_playback();
|
||||
self.info("Queue finished");
|
||||
self.info("Finished playing");
|
||||
}
|
||||
None => self.info("End of queue"),
|
||||
None => self.info("End of the list"),
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
@ -1692,6 +1909,10 @@ 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" }
|
||||
|
|
@ -1754,11 +1975,7 @@ impl App {
|
|||
} else {
|
||||
0
|
||||
};
|
||||
self.queue = self.results.clone();
|
||||
self.played.clear();
|
||||
self.current = None;
|
||||
self.result_cursor = None;
|
||||
self.play_index(start.min(self.queue.len() - 1));
|
||||
self.play_result(start.min(self.results.len() - 1));
|
||||
}
|
||||
|
||||
fn clear_queue(&mut self) {
|
||||
|
|
@ -1909,6 +2126,7 @@ 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!(
|
||||
|
|
@ -1943,12 +2161,9 @@ impl App {
|
|||
return self.error(format!("Playlist \u{201c}{name}\u{201d} is empty"));
|
||||
}
|
||||
let i = i.min(tracks.len() - 1);
|
||||
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);
|
||||
self.radio = tracks;
|
||||
self.radio_played.clear();
|
||||
self.play_radio(i);
|
||||
}
|
||||
|
||||
fn remove_playlist_track(&mut self, i: usize) {
|
||||
|
|
@ -2080,6 +2295,61 @@ 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()));
|
||||
|
|
@ -2174,6 +2444,8 @@ mod tests {
|
|||
app.run_command("remove");
|
||||
assert_eq!(app.queue.len(), 1);
|
||||
|
||||
check_playlist_playback(&mut app);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ pub enum Command {
|
|||
AddTo(String),
|
||||
Import(String),
|
||||
Artist,
|
||||
Similar,
|
||||
Album(Option<String>),
|
||||
PlaylistSearch(String),
|
||||
Lyrics,
|
||||
Help,
|
||||
}
|
||||
|
|
@ -69,6 +72,9 @@ pub const COMMAND_NAMES: &[&str] = &[
|
|||
"addto",
|
||||
"import",
|
||||
"artist",
|
||||
"similar",
|
||||
"album",
|
||||
"playlist",
|
||||
"lyrics",
|
||||
"help",
|
||||
];
|
||||
|
|
@ -177,6 +183,14 @@ 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)")),
|
||||
|
|
@ -242,6 +256,19 @@ 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]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod command;
|
|||
mod config;
|
||||
mod lyrics;
|
||||
mod model;
|
||||
mod mpris;
|
||||
mod player;
|
||||
mod session;
|
||||
mod store;
|
||||
|
|
@ -128,6 +129,12 @@ 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();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ 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();
|
||||
|
|
|
|||
162
src/mpris.rs
Normal file
162
src/mpris.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/* 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;
|
||||
68
src/ui.rs
68
src/ui.rs
|
|
@ -69,6 +69,9 @@ 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);
|
||||
}
|
||||
|
|
@ -801,6 +804,9 @@ 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"),
|
||||
];
|
||||
|
|
@ -1075,10 +1081,19 @@ 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 l", "play · play next · add to queue · artist · playlist"),
|
||||
("p n a g s b l", "play · next · queue · artist · similar · album · playlist"),
|
||||
("x i", "remove · song info"),
|
||||
("Esc q G", "close (Esc goes back from a sub-menu)"),
|
||||
("QUEUE", "#"),
|
||||
|
|
@ -1116,6 +1131,8 @@ 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)", ""),
|
||||
];
|
||||
|
|
@ -1219,6 +1236,55 @@ 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);
|
||||
|
|
|
|||
318
src/ytmusic.rs
318
src/ytmusic.rs
|
|
@ -4,15 +4,26 @@ 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, Track};
|
||||
use crate::model::{is_valid_id, parse_clock, Collection, 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>,
|
||||
|
|
@ -49,6 +60,91 @@ 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());
|
||||
|
|
@ -119,6 +215,7 @@ 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")
|
||||
|
|
@ -220,11 +317,15 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
|
|||
}
|
||||
|
||||
fn column_segments(item: &Value, col: usize) -> 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);
|
||||
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 mut segments = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for run in runs.into_iter().flatten() {
|
||||
|
|
@ -249,6 +350,13 @@ 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) {
|
||||
|
|
@ -271,6 +379,7 @@ fn parse_item(item: &Value) -> Option<Track> {
|
|||
}
|
||||
_ => None,
|
||||
};
|
||||
let duration = duration.or_else(|| fixed_duration(item));
|
||||
let artist = meta
|
||||
.first()
|
||||
.cloned()
|
||||
|
|
@ -289,6 +398,63 @@ 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);
|
||||
|
|
@ -300,6 +466,76 @@ 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}"))
|
||||
}
|
||||
|
|
@ -463,6 +699,76 @@ 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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue