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:
socatlolmeow 2026-10-11 09:54:07 +02:00
commit 8a049b6fca
5 changed files with 420 additions and 37 deletions

View file

@ -12,17 +12,25 @@ use crate::model::{split_artists, RepeatMode, Track};
use crate::player::{Player, PlayerEvent}; use crate::player::{Player, PlayerEvent};
use crate::session::Session; use crate::session::Session;
use crate::store::Store; use crate::store::Store;
use crate::ytmusic; use crate::ytmusic::{self, Page};
pub enum AppEvent { pub enum AppEvent {
Player(u64, PlayerEvent), Player(u64, PlayerEvent),
Results { Results {
id: u64, id: u64,
title: String, 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)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum View { pub enum View {
Results, Results,
@ -103,11 +111,16 @@ pub struct App {
played: HashSet<usize>, played: HashSet<usize>,
fail_streak: u8, fail_streak: u8,
result_cursor: Option<usize>, result_cursor: Option<usize>,
buffered: Vec<Track>,
next_page: Option<String>,
loading_more: bool,
results_gen: u64,
radio: Vec<Track>, radio: Vec<Track>,
on_radio: bool, on_radio: bool,
seek_step: f64, seek_step: f64,
volume_step: f64, volume_step: f64,
autoplay: bool, autoplay: bool,
popular_first: bool,
resume: Option<(String, f64)>, resume: Option<(String, f64)>,
saved: String, saved: String,
save_failed: bool, save_failed: bool,
@ -165,11 +178,16 @@ impl App {
played: HashSet::new(), played: HashSet::new(),
fail_streak: 0, fail_streak: 0,
result_cursor: None, result_cursor: None,
buffered: Vec::new(),
next_page: None,
loading_more: false,
results_gen: 0,
radio: Vec::new(), radio: Vec::new(),
on_radio: false, on_radio: false,
seek_step: p.seek_step, seek_step: p.seek_step,
volume_step: p.volume_step, volume_step: p.volume_step,
autoplay: p.autoplay, autoplay: p.autoplay,
popular_first: cfg.artist.popular_first,
resume: None, resume: None,
saved: String::new(), saved: String::new(),
save_failed: false, save_failed: false,
@ -248,6 +266,7 @@ impl App {
pub fn on_tick(&mut self) { pub fn on_tick(&mut self) {
self.tick = self.tick.wrapping_add(1); self.tick = self.tick.wrapping_add(1);
self.load_more();
if self.tick.is_multiple_of(50) { if self.tick.is_multiple_of(50) {
match self.save_session(false) { match self.save_session(false) {
Ok(()) => self.save_failed = false, Ok(()) => self.save_failed = false,
@ -725,18 +744,28 @@ impl App {
fn search_artist(&mut self, name: String) { fn search_artist(&mut self, name: String) {
let busy = format!("Loading songs by \u{201c}{name}\u{201d}"); let busy = format!("Loading songs by \u{201c}{name}\u{201d}");
let title = format!("Artist: {name}"); let title = format!("Artist: {name}");
let popular_first = self.popular_first;
self.spawn_loader(title, busy, move || { self.spawn_loader(title, busy, move || {
let wanted = name.to_lowercase(); let wanted = name.to_lowercase();
let tracks = ytmusic::search(&name)?; let mut tracks = if popular_first {
Ok(tracks ytmusic::artist_top_songs(&name).unwrap_or_default()
.into_iter() } else {
.filter(|t| { Vec::new()
t.artist.to_lowercase() == wanted };
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) || split_artists(&t.artist)
.iter() .iter()
.any(|a| a.to_lowercase() == wanted) .any(|a| a.to_lowercase() == wanted))
}) }));
.collect()) Ok(tracks.into())
}); });
} }
@ -744,7 +773,7 @@ impl App {
self.spawn_loader( self.spawn_loader(
format!("Search: {query}"), format!("Search: {query}"),
format!("Searching \u{201c}{query}\u{201d}"), 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( self.spawn_loader(
"Imported playlist".into(), "Imported playlist".into(),
"Importing 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) fn spawn_loader<F>(&mut self, title: String, busy: String, work: F)
where where
F: FnOnce() -> Result<Vec<Track>, String> + Send + 'static, F: FnOnce() -> Result<Page, String> + Send + 'static,
{ {
self.search_id += 1; self.search_id += 1;
let id = self.search_id; let id = self.search_id;
@ -778,8 +807,16 @@ impl App {
} }
self.busy = None; self.busy = None;
match result { match result {
Ok(tracks) if tracks.is_empty() => self.info("No results found."), Ok(page) if page.tracks.is_empty() => self.info("No results found."),
Ok(tracks) => { 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(); let n = tracks.len();
self.results = tracks; self.results = tracks;
self.results_title = title; self.results_title = title;
@ -790,6 +827,34 @@ impl App {
Err(e) => self.error(e), 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) => { AppEvent::Player(generation, pe) => {
if generation == self.player_gen { if generation == self.player_gen {
self.on_player(pe); 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> { fn ensure_player(&mut self) -> Result<&mut Player> {
if let Some(p) = self.player.as_mut() { if let Some(p) = self.player.as_mut() {
if !p.is_alive() { if !p.is_alive() {
@ -1535,6 +1630,33 @@ mod tests {
assert!(app.artist_opts.is_empty()); 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] #[test]
fn playlist_drill_in() { fn playlist_drill_in() {
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); 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); assert_eq!(app.store.playlists.len(), 1);
check_artist_popup(&mut app); check_artist_popup(&mut app);
check_paging(&mut app);
app.run_command("remove"); app.run_command("remove");
assert_eq!(app.queue.len(), 1); assert_eq!(app.queue.len(), 1);

View file

@ -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)] #[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub playback: Playback, pub playback: Playback,
pub artist: Artist,
} }
impl Config { impl Config {
@ -59,7 +66,7 @@ impl Config {
Ok(Store::dir()?.join("config.toml")) Ok(Store::dir()?.join("config.toml"))
} }
pub fn render(p: &Playback) -> String { pub fn render(p: &Playback, a: &Artist) -> String {
format!( format!(
"\ "\
# Drift settings. Drift only reads this file and never rewrites it, so your # Drift settings. Drift only reads this file and never rewrites it, so your
@ -73,6 +80,10 @@ autoplay = {autoplay}
restore_session = {restore} restore_session = {restore}
seek_step = {seek:?} seek_step = {seek:?}
volume_step = {vstep:?} 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, volume = p.volume,
shuffle = p.shuffle, shuffle = p.shuffle,
@ -81,6 +92,7 @@ volume_step = {vstep:?}
restore = p.restore_session, restore = p.restore_session,
seek = p.seek_step, seek = p.seek_step,
vstep = p.volume_step, vstep = p.volume_step,
popular = a.popular_first,
) )
} }
@ -95,7 +107,7 @@ volume_step = {vstep:?}
let text = match fs::read_to_string(&path) { let text = match fs::read_to_string(&path) {
Ok(t) => t, Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { 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); return (Config::default(), None);
} }
Err(e) => { Err(e) => {
@ -174,7 +186,7 @@ pub fn migrate_legacy() {
let _ = Session::write(&s); let _ = Session::write(&s);
} }
} }
let _ = Config::write(&Config::render(&p)); let _ = Config::write(&Config::render(&p, &Artist::default()));
} }
#[cfg(test)] #[cfg(test)]
@ -183,13 +195,22 @@ mod tests {
#[test] #[test]
fn default_file_parses_back() { 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 c: Config = toml::from_str(&text).unwrap();
let d = Playback::default(); let d = Playback::default();
assert_eq!(c.playback.volume, d.volume); assert_eq!(c.playback.volume, d.volume);
assert_eq!(c.playback.repeat, d.repeat); assert_eq!(c.playback.repeat, d.repeat);
assert_eq!(c.playback.seek_step, 5.0); assert_eq!(c.playback.seek_step, 5.0);
assert!(c.playback.restore_session); 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] #[test]

View file

@ -51,11 +51,11 @@ fn missing_dependencies(verbose: bool) -> Vec<String> {
for (tool, hint) in [ for (tool, hint) in [
( (
"mpv", "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", "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) { match tool_version(tool) {

View file

@ -1,12 +1,114 @@
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value}; 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)] #[derive(Debug)]
pub enum PlayerEvent { pub enum PlayerEvent {
Position(Option<f64>), Position(Option<f64>),
@ -21,15 +123,13 @@ pub enum PlayerEvent {
pub struct Player { pub struct Player {
child: Child, child: Child,
writer: UnixStream, writer: ipc::Writer,
sock_path: PathBuf, sock_path: PathBuf,
} }
impl Player { impl Player {
pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result<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 = ipc::endpoint();
let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&sock_path);
let mut cmd = Command::new("mpv"); let mut cmd = Command::new("mpv");
cmd.args([ cmd.args([
@ -57,6 +157,7 @@ impl Player {
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()); .stderr(Stdio::null());
// Linux only: ask the kernel to SIGTERM mpv if drift dies unexpectedly.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
unsafe { unsafe {
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
@ -75,8 +176,8 @@ impl Player {
})?; })?;
let start = Instant::now(); let start = Instant::now();
let stream = loop { let (reader, writer) = loop {
match UnixStream::connect(&sock_path) { match ipc::connect(&sock_path) {
Ok(s) => break s, Ok(s) => break s,
Err(e) => { Err(e) => {
if let Ok(Some(status)) = child.try_wait() { 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 { let mut player = Player {
child, child,
writer: stream, writer,
sock_path, sock_path,
}; };
@ -174,7 +274,7 @@ impl Drop for Player {
} }
let _ = self.child.kill(); let _ = self.child.kill();
let _ = self.child.wait(); let _ = self.child.wait();
let _ = std::fs::remove_file(&self.sock_path); ipc::cleanup(&self.sock_path);
} }
} }

View file

@ -7,15 +7,34 @@ use serde_json::{json, Value};
use crate::model::{is_valid_id, parse_clock, Track}; use crate::model::{is_valid_id, parse_clock, Track};
const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false"; 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 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 UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
const FALLBACK_COUNT: usize = 25; 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> { 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) { match search_innertube(query) {
Ok(tracks) if !tracks.is_empty() => Ok(tracks), Ok(page) if !page.tracks.is_empty() => Ok(page),
Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")), Ok(_) => ytdlp_search(query)
Err(primary) => ytdlp_search(query).map_err(|fallback| { .map(Page::from)
.map_err(|e| format!("{e:#}")),
Err(primary) => ytdlp_search(query).map(Page::from).map_err(|fallback| {
format!( format!(
"YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})" "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> { pub fn import(url: &str) -> Result<Vec<Track>, String> {
if !(url.starts_with("http://") || url.starts_with("https://")) { if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("import expects a full http(s) URL".into()); 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) (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() let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(12)) .timeout(Duration::from_secs(12))
.user_agent(UA) .user_agent(UA)
@ -78,7 +104,90 @@ fn search_innertube(query: &str) -> Result<Vec<Track>> {
let v: Value = resp let v: Value = resp
.into_json() .into_json()
.context("invalid JSON from YouTube Music")?; .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>) { 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)); 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] #[test]
fn date_math() { fn date_math() {
assert_eq!(civil_from_days(0), (1970, 1, 1)); assert_eq!(civil_from_days(0), (1970, 1, 1));