mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +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::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);
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
122
src/player.rs
122
src/player.rs
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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};
|
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));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue