feat: discord presence, cached audio playback, multiinstance safety

Add
- Discord rich presence ("Listening to", cover, progress, Listen Along
  button). enable via [discord] client_id in config.toml : retries quietly
  if Discord isn't running. new dep: discord-rich-presence.
- :save! / :w! to overwrite a playlist. Plain :save now refuses to
  clobber an existing name.

Change
- Download audio with yt-dlp into the cache dir and play the local file
  (mpv --ytdl=no) instead of streaming. Keep the last 40 files and
  prefetch the next queued track.
- show the the actual yt-dlp/mpv error on playback failure instead of the
  generic "update yt-dlp" hint; allow wider status messages (70 - 120 char).
- Split artists from YouTube's linked artist runs and yt-dlp's 'artists'
  list (new Track::artists) rather than guessing from the display string.
  Fallback splitter keeps names like "Tyler, The Creator" whole and
  matches separators case-insensitively. (shit)
- Restyle selection (accent bold text + bar) and tab bar (underlined
  active tab instead of pills)
- Share one ureq agent across yt Music requests

Fix
- Multiple drift instances: only the first (flock on session.lock) owns
  the session, and playlist saves merge with what other instances wrote.
- Collection picker no longer steals keys while typing.
- "Play next" now shifts `played` indices so shuffle history stays right.
- Leaving the queue for a playlist/result list marks the queue as done.
- Replace unwrap() in lyrics sorting with total_cmp (NaN-safe).
This commit is contained in:
socatlolmeow 2026-10-29 18:19:00 +01:00
commit eb6b16e70b
16 changed files with 966 additions and 171 deletions

View file

@ -9,9 +9,10 @@ use ratatui::widgets::TableState;
use crate::command::{self, Amount, Command, COMMAND_NAMES};
use crate::config::{self, Config};
use crate::lyrics::{self, Lyrics};
use crate::model::{split_artists, Collection, RepeatMode, Track};
use crate::model::{Collection, RepeatMode, Track};
use crate::discord::Discord;
use crate::mpris::{Cmd, Mpris};
use crate::player::{Player, PlayerEvent};
use crate::player::{self, Player, PlayerEvent};
use crate::session::Session;
use crate::store::Store;
use crate::ytmusic::{self, Kind, Page};
@ -37,6 +38,10 @@ pub enum AppEvent {
result: Result<Vec<Collection>, String>,
},
Media(Cmd),
Audio {
id: String,
result: Result<std::path::PathBuf, String>,
},
}
const FIRST_PAGE: usize = 20;
@ -201,6 +206,8 @@ pub struct App {
radio_played: HashSet<usize>,
resume_radio: bool,
fail_streak: u8,
want: Option<String>,
fetching: Option<String>,
result_cursor: Option<usize>,
buffered: Vec<Track>,
next_page: Option<String>,
@ -210,6 +217,8 @@ pub struct App {
on_radio: bool,
similar_load: Option<u64>,
mpris: Option<Mpris>,
discord: Option<Discord>,
owns_session: bool,
seek_step: f64,
volume_step: f64,
autoplay: bool,
@ -248,6 +257,8 @@ impl App {
let _ = t.send(AppEvent::Media(c));
})
};
let discord = Discord::new(&cfg.discord.client_id);
let owns_session = Session::claim();
let mut app = App {
view: View::Results,
mode: Mode::Normal,
@ -295,6 +306,8 @@ impl App {
radio_played: HashSet::new(),
resume_radio: false,
fail_streak: 0,
want: None,
fetching: None,
result_cursor: None,
buffered: Vec::new(),
next_page: None,
@ -304,6 +317,8 @@ impl App {
on_radio: false,
similar_load: None,
mpris,
discord,
owns_session,
seek_step: p.seek_step,
volume_step: p.volume_step,
autoplay: p.autoplay,
@ -321,7 +336,7 @@ impl App {
tx,
rng: seed,
};
if p.restore_session {
if p.restore_session && owns_session {
let s = Session::load();
app.resume = s
.current
@ -346,9 +361,13 @@ impl App {
}
self.player = None;
self.mpris = None;
self.discord = None;
}
fn save_session(&mut self, force: bool) -> Result<()> {
if !self.owns_session {
return Ok(());
}
let session = Session {
queue: self.queue.clone(),
current: self.current,
@ -390,6 +409,11 @@ impl App {
self.load_more();
self.sync_lyrics();
self.sync_mpris();
self.sync_discord();
/* open the picker once nothing else is being typed, so keys aren't hijacked */
if self.mode == Mode::Normal && !self.picks.is_empty() {
self.mode = Mode::CollectionPick;
}
if self.tick.is_multiple_of(50) {
match self.save_session(false) {
Ok(()) => self.save_failed = false,
@ -812,6 +836,11 @@ impl App {
let len = self.queue.len();
let at = self.current.map_or(0, |c| (c + 1).min(len));
self.queue.insert(at, t);
self.played = self
.played
.iter()
.map(|&i| if i >= at { i + 1 } else { i })
.collect();
self.info(format!("Playing next: {label}"));
}
@ -1103,7 +1132,8 @@ impl App {
self.repeat = m.unwrap_or_else(|| self.repeat.cycle());
self.info(format!("Repeat: {}", self.repeat.label()));
}
Command::Save(name) => self.save_playlist(&name),
Command::Save(name) => self.save_playlist(&name, false),
Command::SaveForce(name) => self.save_playlist(&name, true),
Command::Load(name) => match self.store.resolve(&name) {
Some(n) => self.load_playlist(&n, true),
None => self.error(format!("no playlist named '{name}'")),
@ -1147,7 +1177,7 @@ impl App {
let Some(track) = self.artist_target() else {
return self.error("No song selected");
};
let names = split_artists(&track.artist);
let names = track.artist_names();
if names.is_empty() || track.artist == "Unknown artist" {
return self.error("No artist known for this song");
}
@ -1219,7 +1249,7 @@ impl App {
tracks.extend(found.into_iter().filter(|t| {
!known.contains(&t.id)
&& (t.artist.to_lowercase() == wanted
|| split_artists(&t.artist)
|| t.artist_names()
.iter()
.any(|a| a.to_lowercase() == wanted))
}));
@ -1234,7 +1264,8 @@ impl App {
let Some(album) = track.album.clone() else {
return self.error("No album known for this song");
};
let artist = split_artists(&track.artist)
let artist = track
.artist_names()
.into_iter()
.next()
.unwrap_or_default();
@ -1337,6 +1368,13 @@ impl App {
}
}
fn sync_discord(&mut self) {
if let Some(mut d) = self.discord.take() {
d.update(self.now_track(), self.play_state, self.position);
self.discord = Some(d);
}
}
fn go_similar(&mut self) {
let Some(track) = self.artist_target() else {
return self.error("No song selected");
@ -1449,7 +1487,6 @@ impl App {
self.picks = list;
self.pick_sel = 0;
self.pick_title = title;
self.mode = Mode::CollectionPick;
}
Err(e) => self.error(e),
}
@ -1465,6 +1502,7 @@ impl App {
Err(e) => LyricsState::Failed(e),
};
}
AppEvent::Audio { id, result } => self.on_audio(id, result),
AppEvent::Player(generation, pe) => {
if generation == self.player_gen {
self.on_player(pe);
@ -1546,6 +1584,7 @@ impl App {
PlayerEvent::FileLoaded => {
self.play_state = PlayState::Playing;
self.fail_streak = 0;
self.prefetch_next();
if let Some((id, pos)) = self.resume.take() {
let ok = self.now_track().is_some_and(|t| {
t.id == id && t.duration.is_none_or(|d| pos < f64::from(d) - 1.0)
@ -1567,7 +1606,7 @@ impl App {
if self.fail_streak >= 3 {
self.stop_playback();
self.error(format!(
"Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U"
"Playback failed 3 times in a row: {msg}"
));
} else {
self.error(format!(
@ -1586,6 +1625,60 @@ impl App {
}
}
fn fetch_audio(&mut self, id: &str) {
if self.fetching.as_deref() == Some(id) {
return;
}
self.fetching = Some(id.to_string());
let tx = self.tx.clone();
let id = id.to_string();
std::thread::spawn(move || {
let result = player::fetch_audio(&id);
let _ = tx.send(AppEvent::Audio { id, result });
});
}
fn load_track(&mut self, id: &str) {
self.want = Some(id.to_string());
match player::cached_audio(id) {
Some(path) => self.on_audio(id.to_string(), Ok(path)),
None => self.fetch_audio(id),
}
}
fn on_audio(&mut self, id: String, result: Result<std::path::PathBuf, String>) {
if self.fetching.as_deref() == Some(id.as_str()) {
self.fetching = None;
}
if self.want.as_deref() != Some(id.as_str()) {
return;
}
self.want = None;
match result {
Ok(path) => {
let path = path.to_string_lossy().into_owned();
if let Err(e) = self.with_player(|p| p.load(&path)) {
self.play_state = PlayState::Stopped;
self.error(format!("Playback failed: {e:#}"));
}
}
Err(msg) => self.on_player(PlayerEvent::LoadError(msg)),
}
}
fn prefetch_next(&mut self) {
if self.shuffle || self.on_radio || self.fetching.is_some() {
return;
}
let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else {
return;
};
let id = next.id.clone();
if player::cached_audio(&id).is_none() {
self.fetch_audio(&id);
}
}
fn play_index(&mut self, i: usize) {
self.start_track(i, false);
}
@ -1606,14 +1699,8 @@ impl App {
_ => 0.0,
};
self.duration = track.duration.map_or(0.0, f64::from);
let url = track.url();
match self.with_player(|p| p.load(&url)) {
Ok(()) => self.info(format!("♪ {}", track.label())),
Err(e) => {
self.play_state = PlayState::Stopped;
self.error(format!("Playback failed: {e:#}"));
}
}
self.info(format!("♪ {}", track.label()));
self.load_track(&track.id);
}
fn play_default(&mut self) {
@ -1690,6 +1777,12 @@ impl App {
let Some(track) = self.radio.get(i).cloned() else {
return;
};
if !self.on_radio && self.current.is_some() {
/* leaving the queue for a list: what is queued now counts as done,
only songs added from here on play after this one */
self.current = self.queue.len().checked_sub(1);
self.played = (0..self.queue.len()).collect();
}
self.on_radio = true;
self.resume_radio = true;
self.result_cursor = Some(i);
@ -1698,14 +1791,8 @@ impl App {
self.play_state = PlayState::Loading;
self.position = 0.0;
self.duration = track.duration.map_or(0.0, f64::from);
let url = track.url();
match self.with_player(|p| p.load(&url)) {
Ok(()) => self.info(format!("♪ {}", track.label())),
Err(e) => {
self.play_state = PlayState::Stopped;
self.error(format!("Playback failed: {e:#}"));
}
}
self.info(format!("♪ {}", track.label()));
self.load_track(&track.id);
}
fn set_pause(&mut self, pause: bool) {
@ -1736,6 +1823,7 @@ impl App {
}
fn stop_playback(&mut self) {
self.want = None;
if let Some(p) = self.player.as_mut() {
let _ = p.stop();
}
@ -2068,17 +2156,25 @@ impl App {
}
}
fn save_playlist(&mut self, name: &str) {
fn save_playlist(&mut self, name: &str, force: bool) {
if self.queue.is_empty() {
return self.error("Queue is empty — nothing to save");
}
let key = self
.store
.resolve_exact(name)
.unwrap_or_else(|| name.to_string());
let exists = self.store.playlists.get(&key).is_some_and(|l| !l.is_empty());
if exists && !force {
return self.error(format!(
"Playlist \u{201c}{key}\u{201d} already exists. :save! {key} replaces it, :append {key} adds the queue to the end"
));
}
let n = self.queue.len();
self.store
.playlists
.insert(name.to_string(), self.queue.clone());
self.store.playlists.insert(key.clone(), self.queue.clone());
if self.persist() {
self.info(format!(
"Saved {n} tracks to playlist \u{201c}{name}\u{201d}"
"Saved {n} tracks to playlist \u{201c}{key}\u{201d}"
));
}
}
@ -2242,6 +2338,7 @@ mod tests {
id: id.into(),
title: title.into(),
artist: "Artist".into(),
artists: Vec::new(),
album: None,
duration: Some(100),
}

View file

@ -30,6 +30,7 @@ pub enum Command {
Shuffle,
Repeat(Option<RepeatMode>),
Save(String),
SaveForce(String),
Load(String),
Append(String),
DeletePlaylist(String),
@ -172,6 +173,7 @@ pub fn parse(line: &str) -> Result<Option<Command>, String> {
other => return Err(format!(":repeat expects off|all|one, got '{other}'")),
},
"save" | "w" => Save(name_arg(arg, "save")?),
"save!" | "w!" => SaveForce(name_arg(arg, "save!")?),
"load" | "e" => Load(name_arg(arg, "load")?),
"append" => Append(name_arg(arg, "append")?),
"delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?),
@ -278,5 +280,9 @@ mod tests {
Ok(Some(Command::Save("my mix".into())))
);
assert!(parse("save").is_err());
assert_eq!(
parse("save! my mix"),
Ok(Some(Command::SaveForce("my mix".into())))
);
}
}

View file

@ -74,6 +74,12 @@ pub struct Artist {
pub popular_first: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Discord {
pub client_id: String,
}
fn default_accent() -> Color {
Color::Cyan
}
@ -97,6 +103,7 @@ pub struct Config {
pub playback: Playback,
pub artist: Artist,
pub ui: Ui,
pub discord: Discord,
}
impl Config {
@ -104,7 +111,7 @@ impl Config {
Ok(Store::dir()?.join("config.toml"))
}
pub fn render(p: &Playback, a: &Artist, u: &Ui) -> String {
pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord) -> String {
let accent = match u.accent {
Color::Reset => "reset",
Color::Black => "black",
@ -150,6 +157,12 @@ popular_first = {popular}
# light-cyan, white
# Or use a hex value: \"#FF8800\"
accent = \"{accent}\"
[discord]
# Show the current song in your Discord status. Create an application at
# https://discord.com/developers/applications (its name is shown after
# \"Listening to\") and paste its Application ID here. Empty means off.
client_id = \"{client_id}\"
",
volume = p.volume,
shuffle = p.shuffle,
@ -160,6 +173,7 @@ accent = \"{accent}\"
vstep = p.volume_step,
popular = a.popular_first,
accent = accent,
client_id = d.client_id,
)
}
@ -174,7 +188,7 @@ accent = \"{accent}\"
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(), &Artist::default(), &Ui::default()));
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default()));
return (Config::default(), None);
}
Err(e) => {
@ -253,7 +267,7 @@ pub fn migrate_legacy() {
let _ = Session::write(&s);
}
}
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default()));
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default()));
}
#[cfg(test)]
@ -262,7 +276,7 @@ mod tests {
#[test]
fn default_file_parses_back() {
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default());
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default());
let c: Config = toml::from_str(&text).unwrap();
let d = Playback::default();
assert_eq!(c.playback.volume, d.volume);
@ -329,7 +343,7 @@ mod tests {
// Roundtrip: render then parse
let u = Ui { accent: Color::Blue };
let text = Config::render(&Playback::default(), &Artist::default(), &u);
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default());
let c: Config = toml::from_str(&text).unwrap();
assert_eq!(c.ui.accent, Color::Blue);
}

171
src/discord.rs Normal file
View file

@ -0,0 +1,171 @@
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use discord_rich_presence::activity::{Activity, ActivityType, Assets, Button, Timestamps};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use crate::app::PlayState;
use crate::model::Track;
const RETRY: Duration = Duration::from_secs(5);
const DRIFT_TOLERANCE_MS: i64 = 2500;
enum Msg {
Show(Presence),
Clear,
}
struct Presence {
title: String,
artist: String,
album: Option<String>,
cover: String,
url: String,
paused: bool,
start: i64,
end: Option<i64>,
}
pub struct Discord {
tx: Sender<Msg>,
track: Option<String>,
state: PlayState,
start: i64,
}
impl Discord {
pub fn new(client_id: &str) -> Option<Discord> {
let id = client_id.trim();
if id.is_empty() {
return None;
}
let id = id.to_string();
let (tx, rx) = mpsc::channel();
thread::Builder::new()
.name("discord".into())
.spawn(move || run(&id, rx))
.ok()?;
Some(Discord {
tx,
track: None,
state: PlayState::Stopped,
start: 0,
})
}
pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) {
let Some(track) = track.filter(|_| state != PlayState::Stopped) else {
if self.state != PlayState::Stopped {
self.state = PlayState::Stopped;
self.track = None;
let _ = self.tx.send(Msg::Clear);
}
return;
};
let position = if position.is_finite() { position.max(0.0) } else { 0.0 };
let start = now_ms() - (position * 1000.0) as i64;
let paused = state == PlayState::Paused;
let same_track = self.track.as_deref() == Some(track.id.as_str());
let was_paused = self.state == PlayState::Paused;
let on_time = paused || (start - self.start).abs() < DRIFT_TOLERANCE_MS;
if same_track && paused == was_paused && self.state != PlayState::Stopped && on_time {
self.state = state;
return;
}
self.track = Some(track.id.clone());
self.state = state;
self.start = start;
let end = track
.duration
.map(|d| start + i64::from(d) * 1000)
.filter(|_| !paused);
let _ = self.tx.send(Msg::Show(Presence {
title: track.title.clone(),
artist: track.artist.clone(),
album: track.album.clone(),
cover: format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", track.id),
url: track.url(),
paused,
start,
end,
}));
}
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn run(client_id: &str, rx: mpsc::Receiver<Msg>) {
let mut client: Option<DiscordIpcClient> = None;
let mut pending: Option<Msg> = None;
loop {
match rx.recv_timeout(RETRY) {
Ok(msg) => pending = Some(msg),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
while let Ok(msg) = rx.try_recv() {
pending = Some(msg);
}
let Some(msg) = pending.take() else { continue };
if client.is_none() {
if matches!(msg, Msg::Clear) {
continue;
}
let mut c = DiscordIpcClient::new(client_id);
if c.connect().is_ok() {
client = Some(c);
}
}
let sent = client.as_mut().is_some_and(|c| send(c, &msg));
if !sent {
client = None;
if matches!(msg, Msg::Show(_)) {
pending = Some(msg);
}
}
}
if let Some(mut c) = client {
let _ = c.clear_activity();
let _ = c.close();
}
}
fn send(client: &mut DiscordIpcClient, msg: &Msg) -> bool {
match msg {
Msg::Clear => client.clear_activity().is_ok(),
Msg::Show(p) => {
let mut assets = Assets::new().large_image(&p.cover);
if let Some(album) = &p.album {
assets = assets.large_text(album);
}
let mut timestamps = Timestamps::new().start(p.start);
if let Some(end) = p.end {
timestamps = timestamps.end(end);
}
let artist = if p.paused {
format!("{} (paused)", p.artist)
} else {
p.artist.clone()
};
let mut activity = Activity::new()
.activity_type(ActivityType::Listening)
.details(&p.title)
.state(&artist)
.assets(assets)
.buttons(vec![Button::new("Listen Along", &p.url)]);
if !p.paused {
activity = activity.timestamps(timestamps);
}
client.set_activity(activity).is_ok()
}
}
}

View file

@ -2,7 +2,7 @@ use std::time::Duration;
use serde_json::Value;
use crate::model::{parse_clock, split_artists, Track};
use crate::model::{parse_clock, Track};
const SEARCH: &str = "https://lrclib.net/api/search";
const UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION"));
@ -102,7 +102,7 @@ fn pick(entries: &[Value], duration: Option<u32>) -> Option<Lyrics> {
.filter(|e| drift(e) <= MAX_DRIFT)
.filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l)))
.collect();
found.sort_by(|a, b| (a.0, a.1).partial_cmp(&(b.0, b.1)).unwrap());
found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
found.into_iter().next().map(|(_, _, l)| l)
}
@ -122,7 +122,8 @@ fn query(params: &[(&str, &str)]) -> Result<Vec<Value>, String> {
}
pub fn fetch(track: &Track) -> Result<Option<Lyrics>, String> {
let artist = split_artists(&track.artist)
let artist = track
.artist_names()
.into_iter()
.next()
.unwrap_or_default();

View file

@ -1,6 +1,7 @@
mod app;
mod command;
mod config;
mod discord;
mod lyrics;
mod model;
mod mpris;

View file

@ -5,6 +5,9 @@ pub struct Track {
pub id: String,
pub title: String,
pub artist: String,
/* individual credited artists when the source gave them to us */
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)]
pub album: Option<String>,
#[serde(default)]
@ -16,6 +19,16 @@ impl Track {
format!("https://music.youtube.com/watch?v={}", self.id)
}
/* Individual artists: the structured list if we have one, else a guess
* made by splitting the display string (old saves, plain yt-dlp). */
pub fn artist_names(&self) -> Vec<String> {
if self.artists.is_empty() {
split_artists(&self.artist)
} else {
self.artists.clone()
}
}
pub fn label(&self) -> String {
format!("{} — {}", self.title, self.artist)
}
@ -29,21 +42,75 @@ pub struct Collection {
pub artist: String,
}
/* names that contain a separator and must stay in one piece */
const WHOLE_NAMES: &[&str] = &[
"Tyler, The Creator",
"Earth, Wind & Fire",
"Emerson, Lake & Palmer",
"Crosby, Stills, Nash & Young",
"Crosby, Stills & Nash",
"Simon & Garfunkel",
"Hall & Oates",
"Brooks & Dunn",
"Mumford & Sons",
"Belle & Sebastian",
"Sly & The Family Stone",
"Ike & Tina Turner",
"Bob Seger & The Silver Bullet Band",
"Hootie & the Blowfish",
"Florence & The Machine",
"Years & Years",
"Kool & The Gang",
"Gladys Knight & The Pips",
"Diana Ross & The Supremes",
"Smokey Robinson & The Miracles",
"Martha Reeves & The Vandellas",
"Huey Lewis & The News",
"Echo & the Bunnymen",
"Prince & The Revolution",
"Bruce Springsteen & The E Street Band",
"Tom Petty & The Heartbreakers",
];
/* credit separators, matched case-insensitively */
const SEPARATORS: &[&str] = &[" & ", ", ", " feat. ", " feat ", " ft. ", " × "];
fn starts_with_ci(s: &str, prefix: &str) -> bool {
s.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
fn push_name(out: &mut Vec<String>, raw: &str) {
let name = raw.trim();
if !name.is_empty() && !out.iter().any(|o| o == name) {
out.push(name.to_string());
}
}
/* Best-effort split of a flattened credit string. Only a fallback: prefer
* Track::artists, which comes straight from the source's own boundaries. */
pub fn split_artists(credit: &str) -> Vec<String> {
const MARK: char = '\u{1}';
let mut s = credit.to_string();
for sep in [
" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ",
] {
s = s.replace(sep, &MARK.to_string());
}
let mut out: Vec<String> = Vec::new();
for part in s.split(MARK) {
let name = part.trim();
if !name.is_empty() && !out.iter().any(|o| o == name) {
out.push(name.to_string());
let mut start = 0;
let mut i = 0;
while i < credit.len() {
let rest = &credit[i..];
let at_piece_start = credit[start..i].trim().is_empty();
if at_piece_start {
if let Some(name) = WHOLE_NAMES.iter().find(|n| starts_with_ci(rest, n)) {
i += name.len();
continue;
}
}
if let Some(sep) = SEPARATORS.iter().find(|sep| starts_with_ci(rest, sep)) {
push_name(&mut out, &credit[start..i]);
i += sep.len();
start = i;
continue;
}
i += rest.chars().next().map_or(1, char::len_utf8);
}
push_name(&mut out, &credit[start..]);
out
}
@ -133,9 +200,45 @@ mod tests {
assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]);
assert_eq!(split_artists("A feat. B"), ["A", "B"]);
assert_eq!(split_artists("A, A"), ["A"]);
assert_eq!(split_artists("Tyler, The Creator"), ["Tyler, The Creator"]);
assert_eq!(
split_artists("Tyler, The Creator & Kali Uchis"),
["Tyler, The Creator", "Kali Uchis"]
);
assert_eq!(
split_artists("Earth, Wind & Fire, Someone"),
["Earth, Wind & Fire", "Someone"]
);
assert!(split_artists(" ").is_empty());
}
#[test]
fn artist_splitting_edge_cases() {
assert_eq!(split_artists("hall & oates"), ["hall & oates"]);
assert_eq!(split_artists(" Tyler, The Creator "), ["Tyler, The Creator"]);
assert_eq!(split_artists("A Feat. B"), ["A", "B"]);
assert_eq!(
split_artists("Drake, Hall & Oates"),
["Drake", "Hall & Oates"]
);
assert_eq!(split_artists("Beyoncé × Jay-Z"), ["Beyoncé", "Jay-Z"]);
}
#[test]
fn artist_names_prefers_structured_list() {
let mut t = Track {
id: "abcdefghijk".into(),
title: "T".into(),
artist: "Blood, Sweat & Tears".into(),
artists: vec!["Blood, Sweat & Tears".into()],
album: None,
duration: None,
};
assert_eq!(t.artist_names(), ["Blood, Sweat & Tears"]);
t.artists.clear();
assert_eq!(t.artist_names(), ["Blood", "Sweat", "Tears"]);
}
#[test]
fn ids() {
assert!(is_valid_id("dQw4w9WgXcQ"));

View file

@ -140,8 +140,7 @@ impl Player {
"--really-quiet",
"--audio-display=no",
"--force-window=no",
"--ytdl=yes",
"--ytdl-format=bestaudio/best",
"--ytdl=no",
"--cache=yes",
"--demuxer-max-bytes=32MiB",
])
@ -211,16 +210,44 @@ impl Player {
] {
player.send(json!(["observe_property", id, prop]))?;
}
player.send(json!(["request_log_messages", "error"]))?;
std::thread::spawn(move || {
let mut reason: Option<String> = None;
for line in BufReader::new(reader).lines() {
let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<Value>(&line) else {
continue;
};
if let Some(ev) = parse_event(&v) {
on_event(ev);
match v.get("event").and_then(Value::as_str) {
Some("log-message") => {
let prefix = v.get("prefix").and_then(Value::as_str).unwrap_or("");
let text = v.get("text").and_then(Value::as_str).unwrap_or("").trim();
match text.strip_prefix("ERROR: ") {
Some(t) if prefix == "ytdl_hook" => reason = Some(ytdl_reason(t)),
_ if reason.is_none() && !text.is_empty() && !text.starts_with("Failed to open") => {
reason = Some(text.lines().next().unwrap_or(text).to_string());
}
_ => {}
}
continue;
}
Some("start-file") => reason = None,
_ => {}
}
let Some(mut ev) = parse_event(&v) else {
continue;
};
match &mut ev {
PlayerEvent::LoadError(msg) => {
if let Some(r) = reason.take() {
*msg = r;
}
}
PlayerEvent::FileLoaded => reason = None,
_ => {}
}
on_event(ev);
}
on_event(PlayerEvent::Died);
});
@ -241,9 +268,9 @@ impl Player {
matches!(self.child.try_wait(), Ok(None))
}
pub fn load(&mut self, url: &str) -> Result<()> {
pub fn load(&mut self, path: &str) -> Result<()> {
self.send(json!(["set_property", "pause", false]))?;
self.send(json!(["loadfile", url, "replace"]))
self.send(json!(["loadfile", path, "replace"]))
}
pub fn set_pause(&mut self, pause: bool) -> Result<()> {
@ -282,6 +309,82 @@ impl Drop for Player {
}
}
const KEEP_FILES: usize = 40;
fn audio_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("drift")
.join("audio")
}
pub fn cached_audio(id: &str) -> Option<PathBuf> {
std::fs::read_dir(audio_dir())
.ok()?
.flatten()
.map(|e| e.path())
.find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id))
}
pub fn fetch_audio(id: &str) -> Result<PathBuf, String> {
if let Some(path) = cached_audio(id) {
return Ok(path);
}
let dir = audio_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let mut cmd = Command::new("yt-dlp");
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"])
.arg("-P")
.arg(&dir)
.args(["-o", "%(id)s.%(ext)s"])
.arg(format!("https://music.youtube.com/watch?v={id}"))
.stdin(Stdio::null())
.stdout(Stdio::null());
crate::sys::no_window(&mut cmd);
let out = cmd
.output()
.map_err(|e| format!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;
if let Some(path) = cached_audio(id).filter(|_| out.status.success()) {
prune(&dir);
return Ok(path);
}
let stderr = String::from_utf8_lossy(&out.stderr);
Err(stderr
.lines()
.rev()
.find_map(|l| l.strip_prefix("ERROR: "))
.map(ytdl_reason)
.unwrap_or_else(|| "yt-dlp downloaded nothing".into()))
}
fn prune(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<_> = entries
.flatten()
.filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
.collect();
if files.len() > KEEP_FILES {
files.sort();
for (_, path) in &files[..files.len() - KEEP_FILES] {
let _ = std::fs::remove_file(path);
}
}
}
pub fn ytdl_reason(text: &str) -> String {
let text = text.split("; please report").next().unwrap_or(text).trim();
let text = match text.strip_prefix('[').and_then(|t| t.split_once("] ")) {
Some((_, rest)) => match rest.split_once(": ") {
Some((id, msg)) if id.len() == 11 && !id.contains(' ') => msg,
_ => rest,
},
None => text,
};
text.trim().to_string()
}
fn parse_event(v: &Value) -> Option<PlayerEvent> {
match v.get("event")?.as_str()? {
"property-change" => {
@ -359,6 +462,19 @@ mod tests {
assert!(eof, "end-of-file not seen");
}
#[test]
fn ytdl_reason_is_trimmed() {
assert_eq!(
ytdl_reason("[youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies. \n"),
"Sign in to confirm you're not a bot. Use --cookies."
);
assert_eq!(
ytdl_reason("[youtube] dQw4w9WgXcQ: Failed to extract any player response; please report this issue on https://x"),
"Failed to extract any player response"
);
assert_eq!(ytdl_reason("Unsupported URL: foo"), "Unsupported URL: foo");
}
#[test]
fn event_parsing() {
let v: Value = serde_json::from_str(

View file

@ -22,6 +22,37 @@ impl Session {
Ok(dir.join("drift").join("session.json"))
}
/* The first drift to start owns the saved session. Later ones neither
restore nor overwrite it. The lock goes away when the process exits. */
#[cfg(unix)]
pub fn claim() -> bool {
use std::os::unix::io::AsRawFd;
let Ok(path) = Self::path().map(|p| p.with_file_name("session.lock")) else {
return true;
};
if let Some(dir) = path.parent() {
let _ = fs::create_dir_all(dir);
}
let Ok(file) = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
else {
return true;
};
let ok = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
if ok {
std::mem::forget(file);
}
ok
}
#[cfg(not(unix))]
pub fn claim() -> bool {
true
}
pub fn exists() -> bool {
Self::path().is_ok_and(|p| p.exists())
}
@ -75,6 +106,7 @@ mod tests {
id: id.into(),
title: "T".into(),
artist: "A".into(),
artists: Vec::new(),
album: None,
duration: Some(200),
}

View file

@ -10,6 +10,32 @@ use crate::model::Track;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Store {
pub playlists: BTreeMap<String, Vec<Track>>,
/* what the file looked like when we last read or wrote it */
#[serde(skip)]
base: BTreeMap<String, Vec<Track>>,
}
/* keep whatever another drift wrote, but apply the playlists this one changed */
fn merge(
base: &BTreeMap<String, Vec<Track>>,
mine: &BTreeMap<String, Vec<Track>>,
disk: BTreeMap<String, Vec<Track>>,
) -> BTreeMap<String, Vec<Track>> {
let mut out = disk;
for name in base.keys().chain(mine.keys()) {
if base.get(name) == mine.get(name) {
continue;
}
match mine.get(name) {
Some(list) => {
out.insert(name.clone(), list.clone());
}
None => {
out.remove(name);
}
}
}
out
}
pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
@ -84,7 +110,10 @@ impl Store {
}
};
match serde_json::from_str::<Store>(&text) {
Ok(s) => (s, None),
Ok(mut s) => {
s.base = s.playlists.clone();
(s, None)
}
Err(e) => {
let bak = path.with_extension("json.bak");
let _ = fs::rename(&path, &bak);
@ -99,8 +128,18 @@ impl Store {
}
}
pub fn save(&self) -> Result<()> {
write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?)
fn read_disk() -> Option<BTreeMap<String, Vec<Track>>> {
let text = fs::read_to_string(Self::path().ok()?).ok()?;
serde_json::from_str::<Store>(&text).ok().map(|s| s.playlists)
}
pub fn save(&mut self) -> Result<()> {
if let Some(disk) = Self::read_disk() {
self.playlists = merge(&self.base, &self.playlists, disk);
}
write_atomic(&Self::path()?, &serde_json::to_string_pretty(&*self)?)?;
self.base = self.playlists.clone();
Ok(())
}
pub fn resolve_exact(&self, name: &str) -> Option<String> {
@ -134,6 +173,33 @@ impl Store {
mod tests {
use super::*;
#[test]
fn merge_keeps_other_instances_changes() {
let t = |id: &str| Track {
id: id.into(),
title: "T".into(),
artist: "A".into(),
artists: Vec::new(),
album: None,
duration: None,
};
let base: BTreeMap<String, Vec<Track>> = BTreeMap::from([
("a".to_string(), vec![t("aaaaaaaaaaa")]),
("b".to_string(), vec![]),
]);
let mut mine = base.clone();
mine.insert("c".into(), vec![t("ccccccccccc")]);
mine.remove("b");
let mut disk = base.clone();
disk.insert("d".into(), vec![t("ddddddddddd")]);
disk.get_mut("a").unwrap().push(t("eeeeeeeeeee"));
let out = merge(&base, &mine, disk);
let names: Vec<&str> = out.keys().map(String::as_str).collect();
assert_eq!(names, ["a", "c", "d"]);
assert_eq!(out["a"].len(), 2);
}
#[test]
fn exact_ignores_prefixes() {
let mut s = Store::default();

View file

@ -10,11 +10,19 @@ use crate::model::{fmt_time, Track};
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/* Cursor row: accent-colored bold text plus a bar, no filled background. */
const CURSOR: &str = "▎";
fn selected_style(accent: Color) -> Style {
Style::new()
.fg(Color::Black)
.bg(accent)
.add_modifier(Modifier::BOLD)
Style::new().fg(accent).add_modifier(Modifier::BOLD)
}
/* Swap the leading space of a menu line for the cursor bar. */
fn cursor_text(text: &str) -> String {
match text.strip_prefix(' ') {
Some(rest) => format!("{CURSOR}{rest}"),
None => text.to_string(),
}
}
fn header_style() -> Style {
@ -115,21 +123,19 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
("Queue", app.queue.len()),
("Playlists", app.store.playlists.len()),
];
let pill = Style::new().fg(Color::Black).bg(app.accent);
let active = Style::new()
.fg(app.accent)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
let mut spans: Vec<Span> = Vec::new();
for (i, (title, count)) in titles.into_iter().enumerate() {
spans.push(Span::raw(" "));
if i == app.view.idx() {
spans.push(Span::styled("▐", Style::new().fg(app.accent)));
spans.push(Span::styled(format!(" {title} "), pill.bold()));
spans.push(Span::styled(format!("{count} "), pill));
spans.push(Span::styled("▌", Style::new().fg(app.accent)));
spans.push(Span::styled(format!("{title} {count}"), active));
} else {
spans.push(Span::raw(" "));
spans.push(Span::styled(format!(" {title} "), Style::new().fg(Color::Gray)));
spans.push(Span::styled(format!("{count} "), dim()));
spans.push(Span::raw(" "));
spans.push(Span::styled(title, Style::new().fg(Color::Gray)));
spans.push(Span::styled(format!(" {count}"), dim()));
}
spans.push(Span::raw(" "));
spans.push(Span::raw(" "));
}
f.render_widget(Paragraph::new(Line::from(spans)), tabs);
@ -215,6 +221,7 @@ fn track_table<'a>(rows: Vec<Row<'a>>, show_album: bool, block: Block<'a>, accen
.block(block)
.column_spacing(1)
.row_highlight_style(selected_style(accent))
.highlight_symbol(CURSOR)
}
fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) {
@ -364,7 +371,8 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
)
.header(Row::new(vec!["", "Name", "Songs"]).style(header_style()))
.block(block)
.row_highlight_style(selected_style(app.accent));
.row_highlight_style(selected_style(app.accent))
.highlight_symbol(CURSOR);
f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]);
let sel = app.selected();
@ -694,7 +702,7 @@ fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
Level::Error => ("✖", Color::Red),
Level::Info => ("●", Color::Green),
};
let max_text = (body.width as usize).saturating_sub(8).min(70);
let max_text = (body.width as usize).saturating_sub(8).min(120);
let mut text: String = s.text.lines().next().unwrap_or("").to_string();
if text.chars().count() > max_text {
text = text.chars().take(max_text.saturating_sub(1)).collect::<String>() + "…";
@ -931,7 +939,7 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) {
let text: String = text.chars().take(width).collect();
lines.push(if Some(i) == sel {
let pad = width.saturating_sub(text.chars().count());
Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent))
Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent))
} else {
Line::from(vec![
Span::styled(
@ -1001,7 +1009,7 @@ fn draw_menu(f: &mut Frame, app: &App, area: Rect) {
}
let text: String = text.chars().take(width).collect();
let pad = width.saturating_sub(text.chars().count());
return Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(accent));
return Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(accent));
}
let danger = matches!(
it.action,
@ -1122,7 +1130,7 @@ const HELP: &[(&str, &str)] = &[
(":clear", "empty the queue"),
(
":save n :load n",
"save the queue as a playlist / load one",
"save the queue as a playlist / load one (:save! n replaces an existing one)",
),
(
":append n :delpl n",
@ -1218,7 +1226,7 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) {
.map(|(i, name)| {
let text = format!(" {} {name}", i + 1);
if i == app.artist_sel {
Line::styled(text, selected_style(app.accent))
Line::styled(cursor_text(&text), selected_style(app.accent))
} else {
Line::raw(text)
}
@ -1267,7 +1275,7 @@ fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) {
let text: String = text.chars().take(inner).collect();
if i == app.pick_sel {
let pad = inner.saturating_sub(text.chars().count());
Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent))
Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent))
} else {
Line::raw(text)
}
@ -1350,6 +1358,7 @@ mod tests {
id: id.into(),
title: title.into(),
artist: "Someone".into(),
artists: Vec::new(),
album: None,
duration: Some(90),
};

View file

@ -1,4 +1,5 @@
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result};
@ -176,30 +177,7 @@ fn civil_from_days(z: i64) -> (i64, i64, i64) {
}
fn search_innertube(query: &str) -> Result<Page> {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(12))
.user_agent(UA)
.build();
let body = json!({
"context": { "client": {
"clientName": "WEB_REMIX",
"clientVersion": client_version(),
"hl": "en",
"gl": "US",
}},
"query": query,
"params": SONGS_FILTER,
});
let resp = agent
.post(ENDPOINT)
.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}"))?;
let v: Value = resp
.into_json()
.context("invalid JSON from YouTube Music")?;
let v = post(ENDPOINT, json!({ "query": query, "params": SONGS_FILTER }))?;
Ok(page_from(&v))
}
@ -232,18 +210,25 @@ fn find_continuation(v: &Value) -> Option<String> {
}
}
/* one shared agent so paging and radio reuse the same connection */
fn agent() -> &'static ureq::Agent {
static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
AGENT.get_or_init(|| {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(12))
.user_agent(UA)
.build()
})
}
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
let resp = agent()
.post(url)
.set("Origin", "https://music.youtube.com")
.set("Referer", "https://music.youtube.com/")
@ -316,13 +301,42 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
}
}
fn column_runs(item: &Value, col: usize) -> Option<&Vec<Value>> {
item.get("flexColumns")
.and_then(|c| c.get(col))
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
.and_then(Value::as_array)
}
fn column_segments(item: &Value, col: usize) -> Vec<String> {
run_segments(
item.get("flexColumns")
.and_then(|c| c.get(col))
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
.and_then(Value::as_array),
)
run_segments(column_runs(item, col))
}
/* Artists are the runs that link to an artist/channel page ("UC..." browse
* ids); the "&" and "," between them are plain runs. Using the source's own
* boundaries means names that contain a separator stay in one piece. Names
* must also appear in `credit`, so a stray linked run can't leak in. Empty
* when the response carries no links (the caller falls back to guessing). */
fn artist_names_from_runs(runs: Option<&Vec<Value>>, credit: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for run in runs.into_iter().flatten() {
let id = run
.pointer("/navigationEndpoint/browseEndpoint/browseId")
.and_then(Value::as_str);
let text = run.get("text").and_then(Value::as_str);
let (Some(id), Some(text)) = (id, text) else {
continue;
};
let text = text.trim();
if id.starts_with("UC")
&& !text.is_empty()
&& credit.contains(text)
&& !names.iter().any(|n| n == text)
{
names.push(text.to_string());
}
}
names
}
fn run_segments(runs: Option<&Vec<Value>>) -> Vec<String> {
@ -384,6 +398,7 @@ fn parse_item(item: &Value) -> Option<Track> {
.first()
.cloned()
.unwrap_or_else(|| "Unknown artist".into());
let artists = artist_names_from_runs(column_runs(item, 1), &artist);
let album = meta
.get(1)
.filter(|a| !a.contains(" views") && !a.contains(" plays"))
@ -393,6 +408,7 @@ fn parse_item(item: &Value) -> Option<Track> {
id: id.to_string(),
title,
artist,
artists,
album,
duration,
})
@ -497,11 +513,13 @@ fn parse_panel_item(item: &Value) -> Option<Track> {
if title.is_empty() {
return None;
}
let meta = run_segments(item.pointer("/longBylineText/runs").and_then(Value::as_array));
let byline = item.pointer("/longBylineText/runs").and_then(Value::as_array);
let meta = run_segments(byline);
let artist = meta
.first()
.cloned()
.unwrap_or_else(|| "Unknown artist".into());
let artists = artist_names_from_runs(byline, &artist);
let album = meta
.get(1)
.filter(|a| {
@ -520,6 +538,7 @@ fn parse_panel_item(item: &Value) -> Option<Track> {
id: id.to_string(),
title,
artist,
artists,
album,
duration,
})
@ -585,11 +604,24 @@ pub fn parse_ytdlp_lines(text: &str) -> Vec<Track> {
.unwrap_or("Unknown artist")
.trim_end_matches(" - Topic")
.to_string();
/* newer yt-dlp lists each credited artist separately */
let artists: Vec<String> = v
.get("artists")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default();
let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32);
Some(Track {
id: id.to_string(),
title,
artist,
artists,
album: None,
duration,
})
@ -648,6 +680,36 @@ mod tests {
assert_eq!(t.duration, Some(241));
}
#[test]
fn keeps_linked_artist_runs_whole() {
let link = |text: &str, id: &str| {
json!({"text": text, "navigationEndpoint": {"browseEndpoint": {"browseId": id}}})
};
let resp = json!([{"musicResponsiveListItemRenderer": {
"overlay": {"watchEndpoint": {"videoId": "abcdefghijk"}},
"flexColumns": [
{"musicResponsiveListItemFlexColumnRenderer": run("Song")},
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [
link("Tyler, The Creator", "UCaaaaaaaa"), {"text": " & "},
link("Kali Uchis", "UCbbbbbbbb"), {"text": " • "},
link("Album", "MPREb_cccc"), {"text": " • "}, {"text": "3:21"}
]}}}
]
}}]);
let t = &parse_search_response(&resp)[0];
assert_eq!(t.artist, "Tyler, The Creator & Kali Uchis");
assert_eq!(t.artists, ["Tyler, The Creator", "Kali Uchis"]);
assert_eq!(t.album.as_deref(), Some("Album"));
}
#[test]
fn reads_ytdlp_artists_list() {
let line = r#"{"id":"abcdefghijk","title":"T","artist":"A, B","artists":["A","B"],"duration":10}"#;
let t = parse_ytdlp_lines(line);
assert_eq!(t[0].artists, ["A", "B"]);
assert_eq!(t[0].artist, "A, B");
}
#[test]
fn skips_items_without_video_id() {
let resp = json!([{"musicResponsiveListItemRenderer": {