mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
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:
parent
a7f101d1e7
commit
eb6b16e70b
16 changed files with 966 additions and 171 deletions
157
src/app.rs
157
src/app.rs
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
171
src/discord.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod app;
|
||||
mod command;
|
||||
mod config;
|
||||
mod discord;
|
||||
mod lyrics;
|
||||
mod model;
|
||||
mod mpris;
|
||||
|
|
|
|||
125
src/model.rs
125
src/model.rs
|
|
@ -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"));
|
||||
|
|
|
|||
128
src/player.rs
128
src/player.rs
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
72
src/store.rs
72
src/store.rs
|
|
@ -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();
|
||||
|
|
|
|||
51
src/ui.rs
51
src/ui.rs
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
134
src/ytmusic.rs
134
src/ytmusic.rs
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue