mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-04 03:37:18 +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
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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue