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),
}