Compare commits

...
Author SHA1 Message Date
8da1876835
Add GitHub Actions workflow for Rust project 2026-11-02 19:01:40 +01:00
5237a6bae8 feat(player): stream audio by default, add [audio] config for cookies and downloads
Long tracks (15 min to 1 hour+) didnt playbecause every song was fully
downloaded with yt-dlp into the cache before mpv was started.

Resolve the direct media URL with yt-dlp -g and hand it to mpv, so
playback starts within a couple of seconds. Cached files are still
used when present.

Add an [audio] section to config.toml:
- download: download songs to the cache before playing (default false,
  i.e. stream). Prefetch of the next track only runs in this mode.
- download_max_minutes: in download mode, songs longer than this (or of
  unknown length) are still streamed so 0 means no limit. default is le 15.
- cookies_from_browser: passed to yt-dlp as --cookies-from-browser for
  playback, for when YouTube asks to sign in or blocks a song.
2026-11-02 17:57:31 +01:00
5 changed files with 222 additions and 14 deletions

22
.github/workflows/rust.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: Rust
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose

View file

@ -169,9 +169,16 @@ popular_first = false
accent = "cyan" # a color name or a hex value like "#FF8800"
[discord]
client_id = "" # change this for Discord RPC to work in Drift
client_id = "" # change this for Discord RPC to work in Drift
[audio]
download = false # false: stream songs; true: download to the cache first
download_max_minutes = 15 # with download = true, longer songs are still streamed (0 = no limit)
cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp when YouTube blocks playback
```
By default Drift streams songs, so playback starts right away even for hour long tracks. Set `download = true` to cache songs on disk first (this also prefetches the next song). If YouTube asks you to sign in or playback fails, set `cookies_from_browser` to the browser where youre logged into YouTube. If you alr have a `config.toml`, add the `[audio]` section by hand, since Drift never rewrites it.
Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session.
## Contributing

View file

@ -7,7 +7,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::widgets::TableState;
use crate::command::{self, Amount, Command, COMMAND_NAMES};
use crate::config::{self, Config};
use crate::config::{self, Audio, Config};
use crate::lyrics::{self, Lyrics};
use crate::model::{split_artists, Collection, RepeatMode, Track};
use crate::discord::Discord;
@ -46,6 +46,10 @@ pub enum AppEvent {
id: String,
result: Result<std::path::PathBuf, String>,
},
Stream {
id: String,
result: Result<String, String>,
},
}
const FIRST_PAGE: usize = 20;
@ -215,6 +219,8 @@ pub struct App {
fail_streak: u8,
want: Option<String>,
fetching: Option<String>,
resolving: Option<String>,
audio: Audio,
result_cursor: Option<usize>,
buffered: Vec<Track>,
next_page: Option<String>,
@ -315,6 +321,8 @@ impl App {
fail_streak: 0,
want: None,
fetching: None,
resolving: None,
audio: cfg.audio.clone(),
result_cursor: None,
buffered: Vec::new(),
next_page: None,
@ -1625,6 +1633,7 @@ impl App {
};
}
AppEvent::Audio { id, result } => self.on_audio(id, result),
AppEvent::Stream { id, result } => self.on_stream(id, result),
AppEvent::Player(generation, pe) => {
if generation == self.player_gen {
self.on_player(pe);
@ -1754,17 +1763,53 @@ impl App {
self.fetching = Some(id.to_string());
let tx = self.tx.clone();
let id = id.to_string();
let audio = self.audio.clone();
std::thread::spawn(move || {
let result = player::fetch_audio(&id);
let result = player::fetch_audio(&id, &audio);
let _ = tx.send(AppEvent::Audio { id, result });
});
}
fn load_track(&mut self, id: &str) {
fn load_track(&mut self, id: &str, duration: Option<u32>) {
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),
None if self.audio.should_download(duration) => self.fetch_audio(id),
None => self.resolve_stream(id),
}
}
/* Start playing from the network right away; no full download first. */
fn resolve_stream(&mut self, id: &str) {
if self.resolving.as_deref() == Some(id) {
return;
}
self.resolving = Some(id.to_string());
let tx = self.tx.clone();
let id = id.to_string();
let audio = self.audio.clone();
std::thread::spawn(move || {
let result = player::resolve_stream(&id, &audio);
let _ = tx.send(AppEvent::Stream { id, result });
});
}
fn on_stream(&mut self, id: String, result: Result<String, String>) {
if self.resolving.as_deref() == Some(id.as_str()) {
self.resolving = None;
}
if self.want.as_deref() != Some(id.as_str()) {
return;
}
self.want = None;
match result {
Ok(url) => {
if let Err(e) = self.with_player(|p| p.load(&url)) {
self.play_state = PlayState::Stopped;
self.error(format!("Playback failed: {e:#}"));
}
}
Err(msg) => self.on_player(PlayerEvent::LoadError(msg)),
}
}
@ -1795,6 +1840,10 @@ impl App {
let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else {
return;
};
/* streamed tracks (the default, or too long to download) aren't prefetched */
if !self.audio.should_download(next.duration) {
return;
}
let id = next.id.clone();
if player::cached_audio(&id).is_none() {
self.fetch_audio(&id);
@ -1822,7 +1871,7 @@ impl App {
};
self.duration = track.duration.map_or(0.0, f64::from);
self.info(format!("♪ {}", track.label()));
self.load_track(&track.id);
self.load_track(&track.id, track.duration);
}
fn play_default(&mut self) {
@ -1914,7 +1963,7 @@ impl App {
self.position = 0.0;
self.duration = track.duration.map_or(0.0, f64::from);
self.info(format!("♪ {}", track.label()));
self.load_track(&track.id);
self.load_track(&track.id, track.duration);
}
fn set_pause(&mut self, pause: bool) {

View file

@ -80,6 +80,45 @@ pub struct Discord {
pub client_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Audio {
pub download: bool,
pub download_max_minutes: u32,
pub cookies_from_browser: String,
}
impl Default for Audio {
fn default() -> Self {
Audio {
download: false,
download_max_minutes: 15,
cookies_from_browser: String::new(),
}
}
}
impl Audio {
/// Browser name for yt-dlp's --cookies-from-browser, if one is set.
pub fn cookies(&self) -> Option<&str> {
let c = self.cookies_from_browser.trim();
(!c.is_empty()).then_some(c)
}
/// Whether a track should be downloaded to the cache before playing,
/// as opposed to streamed. Unknown lengths are streamed when a limit is set.
pub fn should_download(&self, duration: Option<u32>) -> bool {
if !self.download {
return false;
}
match (self.download_max_minutes, duration) {
(0, _) => true,
(max, Some(d)) => d <= max.saturating_mul(60),
(_, None) => false,
}
}
}
fn default_accent() -> Color {
Color::Cyan
}
@ -104,6 +143,7 @@ pub struct Config {
pub artist: Artist,
pub ui: Ui,
pub discord: Discord,
pub audio: Audio,
}
impl Config {
@ -111,7 +151,7 @@ impl Config {
Ok(Store::dir()?.join("config.toml"))
}
pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord) -> String {
pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String {
let accent = match u.accent {
Color::Reset => "reset",
Color::Black => "black",
@ -163,6 +203,19 @@ accent = \"{accent}\"
# 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}\"
[audio]
# false: stream songs straight from YouTube (starts playing right away).
# true: download songs to the cache first, then play (and prefetch the next one).
download = {download}
# With download = true, songs longer than this many minutes are still streamed
# instead of downloaded. 0 means download everything, however long.
download_max_minutes = {dl_max}
# Use your browser's cookies with yt-dlp when YouTube refuses to play something
# (\"Sign in to confirm you're not a bot\", age-restricted songs, ...).
# Examples: \"firefox\", \"chrome\", \"chromium\", \"brave\", \"edge\",
# \"firefox:my-profile\". Empty means off. Close the browser if reading fails.
cookies_from_browser = \"{cookies}\"
",
volume = p.volume,
shuffle = p.shuffle,
@ -174,6 +227,9 @@ client_id = \"{client_id}\"
popular = a.popular_first,
accent = accent,
client_id = d.client_id,
download = au.download,
dl_max = au.download_max_minutes,
cookies = au.cookies_from_browser.replace(['\\', '"'], ""),
)
}
@ -188,7 +244,7 @@ client_id = \"{client_id}\"
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(), &Discord::default()));
let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
return (Config::default(), None);
}
Err(e) => {
@ -267,7 +323,7 @@ pub fn migrate_legacy() {
let _ = Session::write(&s);
}
}
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default()));
let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()));
}
#[cfg(test)]
@ -276,7 +332,7 @@ mod tests {
#[test]
fn default_file_parses_back() {
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default());
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default());
let c: Config = toml::from_str(&text).unwrap();
let d = Playback::default();
assert_eq!(c.playback.volume, d.volume);
@ -343,8 +399,42 @@ mod tests {
// Roundtrip: render then parse
let u = Ui { accent: Color::Blue };
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default());
let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default());
let c: Config = toml::from_str(&text).unwrap();
assert_eq!(c.ui.accent, Color::Blue);
}
#[test]
fn audio_section() {
let c: Config = toml::from_str("").unwrap();
assert!(!c.audio.download);
assert_eq!(c.audio.cookies(), None);
let c: Config = toml::from_str(
"[audio]\ndownload = true\ndownload_max_minutes = 10\ncookies_from_browser = \" firefox \"",
)
.unwrap();
assert_eq!(c.audio.cookies(), Some("firefox"));
assert!(c.audio.should_download(Some(600)));
assert!(!c.audio.should_download(Some(601)));
assert!(!c.audio.should_download(None));
let mut a = c.audio.clone();
a.download_max_minutes = 0;
assert!(a.should_download(Some(10_000)));
a.download = false;
assert!(!a.should_download(Some(1)));
}
#[test]
fn audio_roundtrip() {
let a = Audio {
download: true,
download_max_minutes: 30,
cookies_from_browser: "chrome".into(),
};
let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &a);
let c: Config = toml::from_str(&text).unwrap();
assert!(c.audio.download);
assert_eq!(c.audio.download_max_minutes, 30);
assert_eq!(c.audio.cookies(), Some("chrome"));
}
}

View file

@ -6,6 +6,8 @@ use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value};
use crate::config::Audio;
/// Platform-specific transport for mpv's JSON IPC.
/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`).
#[cfg(unix)]
@ -311,6 +313,15 @@ impl Drop for Player {
const KEEP_FILES: usize = 40;
/// Base yt-dlp command with the user's cookie setting applied.
fn ytdlp(audio: &Audio) -> Command {
let mut cmd = Command::new("yt-dlp");
if let Some(browser) = audio.cookies() {
cmd.arg("--cookies-from-browser").arg(browser);
}
cmd
}
fn audio_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
@ -326,13 +337,13 @@ pub fn cached_audio(id: &str) -> Option<PathBuf> {
.find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id))
}
pub fn fetch_audio(id: &str) -> Result<PathBuf, String> {
pub fn fetch_audio(id: &str, audio: &Audio) -> 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");
let mut cmd = ytdlp(audio);
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"])
.arg("-P")
.arg(&dir)
@ -357,6 +368,35 @@ pub fn fetch_audio(id: &str) -> Result<PathBuf, String> {
.unwrap_or_else(|| "yt-dlp downloaded nothing".into()))
}
/// Ask yt-dlp for the direct media URL so mpv can start playing right away
/// instead of waiting for the whole file to download.
pub fn resolve_stream(id: &str, audio: &Audio) -> Result<String, String> {
let mut cmd = ytdlp(audio);
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "-g"])
.arg(format!("https://music.youtube.com/watch?v={id}"))
.stdin(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?"))?;
let stdout = String::from_utf8_lossy(&out.stdout);
if let Some(url) = stdout
.lines()
.map(str::trim)
.find(|l| l.starts_with("http"))
.filter(|_| out.status.success())
{
return Ok(url.to_string());
}
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 returned no stream URL".into()))
}
fn prune(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;