From 8a049b6fca6b24abda813ef51d614f77113fe40a Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Sun, 11 Oct 2026 09:54:07 +0200 Subject: [PATCH] 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 --- src/app.rs | 153 ++++++++++++++++++++++++++++++++++++++++++++----- src/config.rs | 29 ++++++++-- src/main.rs | 4 +- src/player.rs | 122 +++++++++++++++++++++++++++++++++++---- src/ytmusic.rs | 149 +++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 420 insertions(+), 37 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4c91c4a..f4f7409 100644 --- a/src/app.rs +++ b/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, String>, + result: Result, + }, + More { + generation: u64, + result: Result, }, } +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, fail_streak: u8, result_cursor: Option, + buffered: Vec, + next_page: Option, + loading_more: bool, + results_gen: u64, radio: Vec, 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 = 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(&mut self, title: String, busy: String, work: F) where - F: FnOnce() -> Result, String> + Send + 'static, + F: FnOnce() -> Result + 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 = 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 = (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); diff --git a/src/config.rs b/src/config.rs index cd1577c..8e98c62 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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] diff --git a/src/main.rs b/src/main.rs index d685433..96ce68d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,11 +51,11 @@ fn missing_dependencies(verbose: bool) -> Vec { 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) { diff --git a/src/player.rs b/src/player.rs index 37806af..e1985c7 100644 --- a/src/player.rs +++ b/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 { + 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), @@ -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 { - 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); } } diff --git a/src/ytmusic.rs b/src/ytmusic.rs index c6ee46f..77c1bb1 100644 --- a/src/ytmusic.rs +++ b/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, + pub next: Option, +} + +impl From> for Page { + fn from(tracks: Vec) -> Self { + Page { tracks, next: None } + } +} + pub fn search(query: &str) -> Result, String> { + search_page(query).map(|p| p.tracks) +} + +pub fn search_page(query: &str) -> Result { 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, String> { } } +pub fn search_more(token: &str) -> Result { + 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, 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> { +fn search_innertube(query: &str) -> Result { let agent = ureq::AgentBuilder::new() .timeout(Duration::from_secs(12)) .user_agent(UA) @@ -78,7 +104,90 @@ fn search_innertube(query: &str) -> Result> { 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 { + 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 { + 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::() + .context("invalid JSON from YouTube Music") +} + +pub fn artist_top_songs(name: &str) -> Result, String> { + let fetch = || -> Result> { + 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 { + 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));