mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 11:20:20 +00:00
Commit 10/11/26
- Added [artist] in config - Automatically lists new songs when scrolled on :search - You can view most popular songs on artist profile - By adding this to your config: [artist] popular_first = true
This commit is contained in:
parent
a69820e5a3
commit
8a049b6fca
5 changed files with 420 additions and 37 deletions
153
src/app.rs
153
src/app.rs
|
|
@ -12,17 +12,25 @@ use crate::model::{split_artists, RepeatMode, Track};
|
|||
use crate::player::{Player, PlayerEvent};
|
||||
use crate::session::Session;
|
||||
use crate::store::Store;
|
||||
use crate::ytmusic;
|
||||
use crate::ytmusic::{self, Page};
|
||||
|
||||
pub enum AppEvent {
|
||||
Player(u64, PlayerEvent),
|
||||
Results {
|
||||
id: u64,
|
||||
title: String,
|
||||
result: Result<Vec<Track>, String>,
|
||||
result: Result<Page, String>,
|
||||
},
|
||||
More {
|
||||
generation: u64,
|
||||
result: Result<Page, String>,
|
||||
},
|
||||
}
|
||||
|
||||
const FIRST_PAGE: usize = 20;
|
||||
const NEXT_PAGE: usize = 10;
|
||||
const LOAD_AHEAD: usize = 3;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum View {
|
||||
Results,
|
||||
|
|
@ -103,11 +111,16 @@ pub struct App {
|
|||
played: HashSet<usize>,
|
||||
fail_streak: u8,
|
||||
result_cursor: Option<usize>,
|
||||
buffered: Vec<Track>,
|
||||
next_page: Option<String>,
|
||||
loading_more: bool,
|
||||
results_gen: u64,
|
||||
radio: Vec<Track>,
|
||||
on_radio: bool,
|
||||
seek_step: f64,
|
||||
volume_step: f64,
|
||||
autoplay: bool,
|
||||
popular_first: bool,
|
||||
resume: Option<(String, f64)>,
|
||||
saved: String,
|
||||
save_failed: bool,
|
||||
|
|
@ -165,11 +178,16 @@ impl App {
|
|||
played: HashSet::new(),
|
||||
fail_streak: 0,
|
||||
result_cursor: None,
|
||||
buffered: Vec::new(),
|
||||
next_page: None,
|
||||
loading_more: false,
|
||||
results_gen: 0,
|
||||
radio: Vec::new(),
|
||||
on_radio: false,
|
||||
seek_step: p.seek_step,
|
||||
volume_step: p.volume_step,
|
||||
autoplay: p.autoplay,
|
||||
popular_first: cfg.artist.popular_first,
|
||||
resume: None,
|
||||
saved: String::new(),
|
||||
save_failed: false,
|
||||
|
|
@ -248,6 +266,7 @@ impl App {
|
|||
|
||||
pub fn on_tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.load_more();
|
||||
if self.tick.is_multiple_of(50) {
|
||||
match self.save_session(false) {
|
||||
Ok(()) => self.save_failed = false,
|
||||
|
|
@ -725,18 +744,28 @@ impl App {
|
|||
fn search_artist(&mut self, name: String) {
|
||||
let busy = format!("Loading songs by \u{201c}{name}\u{201d}");
|
||||
let title = format!("Artist: {name}");
|
||||
let popular_first = self.popular_first;
|
||||
self.spawn_loader(title, busy, move || {
|
||||
let wanted = name.to_lowercase();
|
||||
let tracks = ytmusic::search(&name)?;
|
||||
Ok(tracks
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
t.artist.to_lowercase() == wanted
|
||||
let mut tracks = if popular_first {
|
||||
ytmusic::artist_top_songs(&name).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let found = match ytmusic::search(&name) {
|
||||
Ok(found) => found,
|
||||
Err(e) if tracks.is_empty() => return Err(e),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let known: HashSet<String> = tracks.iter().map(|t| t.id.clone()).collect();
|
||||
tracks.extend(found.into_iter().filter(|t| {
|
||||
!known.contains(&t.id)
|
||||
&& (t.artist.to_lowercase() == wanted
|
||||
|| split_artists(&t.artist)
|
||||
.iter()
|
||||
.any(|a| a.to_lowercase() == wanted)
|
||||
})
|
||||
.collect())
|
||||
.any(|a| a.to_lowercase() == wanted))
|
||||
}));
|
||||
Ok(tracks.into())
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -744,7 +773,7 @@ impl App {
|
|||
self.spawn_loader(
|
||||
format!("Search: {query}"),
|
||||
format!("Searching \u{201c}{query}\u{201d}"),
|
||||
move || ytmusic::search(&query),
|
||||
move || ytmusic::search_page(&query),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -752,13 +781,13 @@ impl App {
|
|||
self.spawn_loader(
|
||||
"Imported playlist".into(),
|
||||
"Importing playlist".into(),
|
||||
move || ytmusic::import(&url),
|
||||
move || ytmusic::import(&url).map(Page::from),
|
||||
);
|
||||
}
|
||||
|
||||
fn spawn_loader<F>(&mut self, title: String, busy: String, work: F)
|
||||
where
|
||||
F: FnOnce() -> Result<Vec<Track>, String> + Send + 'static,
|
||||
F: FnOnce() -> Result<Page, String> + Send + 'static,
|
||||
{
|
||||
self.search_id += 1;
|
||||
let id = self.search_id;
|
||||
|
|
@ -778,8 +807,16 @@ impl App {
|
|||
}
|
||||
self.busy = None;
|
||||
match result {
|
||||
Ok(tracks) if tracks.is_empty() => self.info("No results found."),
|
||||
Ok(tracks) => {
|
||||
Ok(page) if page.tracks.is_empty() => self.info("No results found."),
|
||||
Ok(page) => {
|
||||
let mut tracks = page.tracks;
|
||||
self.buffered = match page.next {
|
||||
Some(_) if tracks.len() > FIRST_PAGE => tracks.split_off(FIRST_PAGE),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
self.next_page = page.next;
|
||||
self.loading_more = false;
|
||||
self.results_gen += 1;
|
||||
let n = tracks.len();
|
||||
self.results = tracks;
|
||||
self.results_title = title;
|
||||
|
|
@ -790,6 +827,34 @@ impl App {
|
|||
Err(e) => self.error(e),
|
||||
}
|
||||
}
|
||||
AppEvent::More { generation, result } => {
|
||||
if generation != self.results_gen {
|
||||
return;
|
||||
}
|
||||
self.loading_more = false;
|
||||
match result {
|
||||
Ok(page) => {
|
||||
let known: HashSet<&str> = self
|
||||
.results
|
||||
.iter()
|
||||
.chain(&self.buffered)
|
||||
.map(|t| t.id.as_str())
|
||||
.collect();
|
||||
let fresh: Vec<Track> = page
|
||||
.tracks
|
||||
.into_iter()
|
||||
.filter(|t| !known.contains(t.id.as_str()))
|
||||
.collect();
|
||||
self.buffered.extend(fresh);
|
||||
self.next_page = page.next;
|
||||
self.reveal_buffered();
|
||||
}
|
||||
Err(e) => {
|
||||
self.next_page = None;
|
||||
self.error(format!("Could not load more results: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::Player(generation, pe) => {
|
||||
if generation == self.player_gen {
|
||||
self.on_player(pe);
|
||||
|
|
@ -798,6 +863,36 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn reveal_buffered(&mut self) {
|
||||
let n = NEXT_PAGE.min(self.buffered.len());
|
||||
self.results.extend(self.buffered.drain(..n));
|
||||
}
|
||||
|
||||
fn load_more(&mut self) {
|
||||
if self.view != View::Results
|
||||
|| self.results.is_empty()
|
||||
|| self.selected() + LOAD_AHEAD < self.results.len()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.buffered.is_empty() {
|
||||
return self.reveal_buffered();
|
||||
}
|
||||
let Some(token) = self.next_page.clone() else {
|
||||
return;
|
||||
};
|
||||
if self.loading_more {
|
||||
return;
|
||||
}
|
||||
self.loading_more = true;
|
||||
let generation = self.results_gen;
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = ytmusic::search_more(&token);
|
||||
let _ = tx.send(AppEvent::More { generation, result });
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_player(&mut self) -> Result<&mut Player> {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if !p.is_alive() {
|
||||
|
|
@ -1535,6 +1630,33 @@ mod tests {
|
|||
assert!(app.artist_opts.is_empty());
|
||||
}
|
||||
|
||||
fn check_paging(app: &mut App) {
|
||||
let tracks: Vec<Track> = (0..25)
|
||||
.map(|i| track(&format!("{i:011}"), &format!("Song {i}")))
|
||||
.collect();
|
||||
let page = Page {
|
||||
tracks,
|
||||
next: Some("token".into()),
|
||||
};
|
||||
app.search_id = 7;
|
||||
app.on_event(AppEvent::Results {
|
||||
id: 7,
|
||||
title: "Search: x".into(),
|
||||
result: Ok(page),
|
||||
});
|
||||
assert_eq!((app.results.len(), app.buffered.len()), (20, 5));
|
||||
|
||||
app.on_tick();
|
||||
assert_eq!(app.results.len(), 20);
|
||||
app.states[View::Results.idx()].select(Some(18));
|
||||
app.load_more();
|
||||
assert_eq!((app.results.len(), app.buffered.len()), (25, 0));
|
||||
|
||||
app.next_page = None;
|
||||
app.load_more();
|
||||
assert_eq!(app.results.len(), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlist_drill_in() {
|
||||
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id()));
|
||||
|
|
@ -1624,6 +1746,7 @@ mod tests {
|
|||
assert_eq!(app.store.playlists.len(), 1);
|
||||
|
||||
check_artist_popup(&mut app);
|
||||
check_paging(&mut app);
|
||||
|
||||
app.run_command("remove");
|
||||
assert_eq!(app.queue.len(), 1);
|
||||
|
|
|
|||
|
|
@ -48,10 +48,17 @@ impl Playback {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Artist {
|
||||
pub popular_first: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub playback: Playback,
|
||||
pub artist: Artist,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -59,7 +66,7 @@ impl Config {
|
|||
Ok(Store::dir()?.join("config.toml"))
|
||||
}
|
||||
|
||||
pub fn render(p: &Playback) -> String {
|
||||
pub fn render(p: &Playback, a: &Artist) -> String {
|
||||
format!(
|
||||
"\
|
||||
# Drift settings. Drift only reads this file and never rewrites it, so your
|
||||
|
|
@ -73,6 +80,10 @@ autoplay = {autoplay}
|
|||
restore_session = {restore}
|
||||
seek_step = {seek:?}
|
||||
volume_step = {vstep:?}
|
||||
|
||||
[artist]
|
||||
# :artist and ga list the artist's most popular songs first, then more of their songs below
|
||||
popular_first = {popular}
|
||||
",
|
||||
volume = p.volume,
|
||||
shuffle = p.shuffle,
|
||||
|
|
@ -81,6 +92,7 @@ volume_step = {vstep:?}
|
|||
restore = p.restore_session,
|
||||
seek = p.seek_step,
|
||||
vstep = p.volume_step,
|
||||
popular = a.popular_first,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +107,7 @@ volume_step = {vstep:?}
|
|||
let text = match fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let _ = Self::write(&Self::render(&Playback::default()));
|
||||
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default()));
|
||||
return (Config::default(), None);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -174,7 +186,7 @@ pub fn migrate_legacy() {
|
|||
let _ = Session::write(&s);
|
||||
}
|
||||
}
|
||||
let _ = Config::write(&Config::render(&p));
|
||||
let _ = Config::write(&Config::render(&p, &Artist::default()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -183,13 +195,22 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn default_file_parses_back() {
|
||||
let text = Config::render(&Playback::default());
|
||||
let text = Config::render(&Playback::default(), &Artist::default());
|
||||
let c: Config = toml::from_str(&text).unwrap();
|
||||
let d = Playback::default();
|
||||
assert_eq!(c.playback.volume, d.volume);
|
||||
assert_eq!(c.playback.repeat, d.repeat);
|
||||
assert_eq!(c.playback.seek_step, 5.0);
|
||||
assert!(c.playback.restore_session);
|
||||
assert!(!c.artist.popular_first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_section() {
|
||||
let c: Config = toml::from_str("[artist]\npopular_first = true").unwrap();
|
||||
assert!(c.artist.popular_first);
|
||||
let c: Config = toml::from_str("[playback]\nvolume = 10").unwrap();
|
||||
assert!(!c.artist.popular_first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -51,11 +51,11 @@ fn missing_dependencies(verbose: bool) -> Vec<String> {
|
|||
for (tool, hint) in [
|
||||
(
|
||||
"mpv",
|
||||
"sudo apt install mpv | brew install mpv | sudo pacman -S mpv",
|
||||
"sudo apt install mpv | brew install mpv | sudo pacman -S mpv | winget install mpv",
|
||||
),
|
||||
(
|
||||
"yt-dlp",
|
||||
"pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp",
|
||||
"pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp | winget install yt-dlp",
|
||||
),
|
||||
] {
|
||||
match tool_version(tool) {
|
||||
|
|
|
|||
122
src/player.rs
122
src/player.rs
|
|
@ -1,12 +1,114 @@
|
|||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Platform-specific transport for mpv's JSON IPC.
|
||||
/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`).
|
||||
#[cfg(unix)]
|
||||
mod ipc {
|
||||
use std::io;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub type Reader = UnixStream;
|
||||
pub type Writer = UnixStream;
|
||||
|
||||
pub fn endpoint() -> PathBuf {
|
||||
let dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir);
|
||||
let path = dir.join(format!("drift-{}.sock", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
path
|
||||
}
|
||||
|
||||
pub fn connect(path: &Path) -> io::Result<(Reader, Writer)> {
|
||||
let stream = UnixStream::connect(path)?;
|
||||
let reader = stream.try_clone()?;
|
||||
Ok((reader, stream))
|
||||
}
|
||||
|
||||
pub fn cleanup(path: &Path) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod ipc {
|
||||
use std::ffi::c_void;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Read};
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
pub type Writer = File;
|
||||
|
||||
extern "system" {
|
||||
fn PeekNamedPipe(
|
||||
pipe: *mut c_void,
|
||||
buffer: *mut c_void,
|
||||
buffer_size: u32,
|
||||
bytes_read: *mut u32,
|
||||
total_bytes_avail: *mut u32,
|
||||
bytes_left_this_message: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
/// Reader half of the pipe.
|
||||
///
|
||||
/// A blocking `ReadFile` on a synchronous pipe handle also blocks any
|
||||
/// `WriteFile` on a clone of that handle, which would deadlock the player.
|
||||
/// So we poll with `PeekNamedPipe` and only read when data is waiting.
|
||||
pub struct Reader(File);
|
||||
|
||||
impl Read for Reader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
loop {
|
||||
let mut avail: u32 = 0;
|
||||
let ok = unsafe {
|
||||
PeekNamedPipe(
|
||||
self.0.as_raw_handle() as *mut c_void,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
std::ptr::null_mut(),
|
||||
&mut avail,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
// mpv exited / pipe closed: report EOF.
|
||||
return Ok(0);
|
||||
}
|
||||
if avail > 0 {
|
||||
let n = (avail as usize).min(buf.len());
|
||||
return self.0.read(&mut buf[..n]);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn endpoint() -> PathBuf {
|
||||
PathBuf::from(format!(r"\\.\pipe\drift-{}", std::process::id()))
|
||||
}
|
||||
|
||||
pub fn connect(path: &Path) -> io::Result<(Reader, Writer)> {
|
||||
let file = OpenOptions::new().read(true).write(true).open(path)?;
|
||||
let reader = file.try_clone()?;
|
||||
Ok((Reader(reader), file))
|
||||
}
|
||||
|
||||
pub fn cleanup(_path: &Path) {
|
||||
// Named pipes disappear when mpv exits.
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PlayerEvent {
|
||||
Position(Option<f64>),
|
||||
|
|
@ -21,15 +123,13 @@ pub enum PlayerEvent {
|
|||
|
||||
pub struct Player {
|
||||
child: Child,
|
||||
writer: UnixStream,
|
||||
writer: ipc::Writer,
|
||||
sock_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result<Player> {
|
||||
let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir);
|
||||
let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id()));
|
||||
let _ = std::fs::remove_file(&sock_path);
|
||||
let sock_path = ipc::endpoint();
|
||||
|
||||
let mut cmd = Command::new("mpv");
|
||||
cmd.args([
|
||||
|
|
@ -57,6 +157,7 @@ impl Player {
|
|||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
// Linux only: ask the kernel to SIGTERM mpv if drift dies unexpectedly.
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
|
@ -75,8 +176,8 @@ impl Player {
|
|||
})?;
|
||||
|
||||
let start = Instant::now();
|
||||
let stream = loop {
|
||||
match UnixStream::connect(&sock_path) {
|
||||
let (reader, writer) = loop {
|
||||
match ipc::connect(&sock_path) {
|
||||
Ok(s) => break s,
|
||||
Err(e) => {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
|
|
@ -92,10 +193,9 @@ impl Player {
|
|||
}
|
||||
};
|
||||
|
||||
let reader = stream.try_clone().context("cloning mpv socket")?;
|
||||
let mut player = Player {
|
||||
child,
|
||||
writer: stream,
|
||||
writer,
|
||||
sock_path,
|
||||
};
|
||||
|
||||
|
|
@ -174,7 +274,7 @@ impl Drop for Player {
|
|||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
let _ = std::fs::remove_file(&self.sock_path);
|
||||
ipc::cleanup(&self.sock_path);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
149
src/ytmusic.rs
149
src/ytmusic.rs
|
|
@ -7,15 +7,34 @@ use serde_json::{json, Value};
|
|||
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 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;
|
||||
|
||||
pub struct Page {
|
||||
pub tracks: Vec<Track>,
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Vec<Track>> for Page {
|
||||
fn from(tracks: Vec<Track>) -> Self {
|
||||
Page { tracks, next: None }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search(query: &str) -> Result<Vec<Track>, String> {
|
||||
search_page(query).map(|p| p.tracks)
|
||||
}
|
||||
|
||||
pub fn search_page(query: &str) -> Result<Page, String> {
|
||||
match search_innertube(query) {
|
||||
Ok(tracks) if !tracks.is_empty() => Ok(tracks),
|
||||
Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")),
|
||||
Err(primary) => ytdlp_search(query).map_err(|fallback| {
|
||||
Ok(page) if !page.tracks.is_empty() => Ok(page),
|
||||
Ok(_) => ytdlp_search(query)
|
||||
.map(Page::from)
|
||||
.map_err(|e| format!("{e:#}")),
|
||||
Err(primary) => ytdlp_search(query).map(Page::from).map_err(|fallback| {
|
||||
format!(
|
||||
"YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})"
|
||||
)
|
||||
|
|
@ -23,6 +42,13 @@ pub fn search(query: &str) -> Result<Vec<Track>, String> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn search_more(token: &str) -> Result<Page, String> {
|
||||
let url = format!("{ENDPOINT}&ctoken={token}&continuation={token}");
|
||||
post(&url, json!({}))
|
||||
.map(|v| page_from(&v))
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
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());
|
||||
|
|
@ -53,7 +79,7 @@ fn civil_from_days(z: i64) -> (i64, i64, i64) {
|
|||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
fn search_innertube(query: &str) -> Result<Vec<Track>> {
|
||||
fn search_innertube(query: &str) -> Result<Page> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(12))
|
||||
.user_agent(UA)
|
||||
|
|
@ -78,7 +104,90 @@ fn search_innertube(query: &str) -> Result<Vec<Track>> {
|
|||
let v: Value = resp
|
||||
.into_json()
|
||||
.context("invalid JSON from YouTube Music")?;
|
||||
Ok(parse_search_response(&v))
|
||||
Ok(page_from(&v))
|
||||
}
|
||||
|
||||
fn page_from(v: &Value) -> Page {
|
||||
Page {
|
||||
tracks: parse_search_response(v),
|
||||
next: find_continuation(v),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_continuation(v: &Value) -> Option<String> {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
let token = map
|
||||
.get("nextContinuationData")
|
||||
.and_then(|c| c.get("continuation"))
|
||||
.or_else(|| {
|
||||
map.get("continuationCommand")
|
||||
.and_then(|c| c.get("token"))
|
||||
})
|
||||
.and_then(Value::as_str);
|
||||
match token {
|
||||
Some(t) => Some(t.to_string()),
|
||||
None => map.values().find_map(find_continuation),
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => arr.iter().find_map(find_continuation),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn post(url: &str, mut body: Value) -> Result<Value> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(12))
|
||||
.user_agent(UA)
|
||||
.build();
|
||||
body["context"] = json!({ "client": {
|
||||
"clientName": "WEB_REMIX",
|
||||
"clientVersion": client_version(),
|
||||
"hl": "en",
|
||||
"gl": "US",
|
||||
}});
|
||||
let resp = agent
|
||||
.post(url)
|
||||
.set("Origin", "https://music.youtube.com")
|
||||
.set("Referer", "https://music.youtube.com/")
|
||||
.set("Cookie", "SOCS=CAI")
|
||||
.send_json(body)
|
||||
.map_err(|e| anyhow!("request failed: {e}"))?;
|
||||
resp.into_json::<Value>()
|
||||
.context("invalid JSON from YouTube Music")
|
||||
}
|
||||
|
||||
pub fn artist_top_songs(name: &str) -> Result<Vec<Track>, String> {
|
||||
let fetch = || -> Result<Vec<Track>> {
|
||||
let found = post(ENDPOINT, json!({ "query": name, "params": ARTISTS_FILTER }))?;
|
||||
let Some(id) = pick_artist_id(&found, name) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let page = post(BROWSE_ENDPOINT, json!({ "browseId": id }))?;
|
||||
Ok(parse_search_response(&page))
|
||||
};
|
||||
fetch().map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
fn pick_artist_id(v: &Value, name: &str) -> Option<String> {
|
||||
let mut items = Vec::new();
|
||||
collect_items(v, &mut items);
|
||||
let artists: Vec<(String, &str)> = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let id = item
|
||||
.pointer("/navigationEndpoint/browseEndpoint/browseId")?
|
||||
.as_str()?;
|
||||
let title = column_segments(item, 0).into_iter().next()?;
|
||||
id.starts_with("UC").then_some((title, id))
|
||||
})
|
||||
.collect();
|
||||
let wanted = name.to_lowercase();
|
||||
artists
|
||||
.iter()
|
||||
.find(|(title, _)| title.to_lowercase() == wanted)
|
||||
.or(artists.first())
|
||||
.map(|(_, id)| id.to_string())
|
||||
}
|
||||
|
||||
fn collect_items<'a>(v: &'a Value, out: &mut Vec<&'a Value>) {
|
||||
|
|
@ -323,6 +432,36 @@ not json
|
|||
assert_eq!(t[0].duration, Some(213));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_artist_by_name() {
|
||||
let artist = |name: &str, id: &str| {
|
||||
json!({"musicResponsiveListItemRenderer": {
|
||||
"navigationEndpoint": {"browseEndpoint": {"browseId": id}},
|
||||
"flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run(name)}]
|
||||
}})
|
||||
};
|
||||
let resp = json!({"contents": [
|
||||
artist("Daft Punk Tribute", "UCaaa"),
|
||||
artist("Daft Punk", "UCbbb"),
|
||||
]});
|
||||
assert_eq!(pick_artist_id(&resp, "daft punk").as_deref(), Some("UCbbb"));
|
||||
assert_eq!(pick_artist_id(&resp, "Other").as_deref(), Some("UCaaa"));
|
||||
assert_eq!(pick_artist_id(&json!([]), "x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_continuation_tokens() {
|
||||
let old = json!({"musicShelfRenderer": {"contents": [], "continuations": [
|
||||
{"nextContinuationData": {"continuation": "abc%3D", "clickTrackingParams": "x"}}
|
||||
]}});
|
||||
assert_eq!(find_continuation(&old).as_deref(), Some("abc%3D"));
|
||||
let new = json!([{"continuationItemRenderer": {"continuationEndpoint": {
|
||||
"continuationCommand": {"token": "def", "request": "CONTINUATION_REQUEST_TYPE_SEARCH"}
|
||||
}}}]);
|
||||
assert_eq!(find_continuation(&new).as_deref(), Some("def"));
|
||||
assert_eq!(find_continuation(&json!({"a": [1, 2]})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_math() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
|
|
|
|||
Loading…
Reference in a new issue