diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..9fd45e0 --- /dev/null +++ b/.github/workflows/rust.yml @@ -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 diff --git a/README.md b/README.md index 550d231..4ec6a77 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app.rs b/src/app.rs index 1f49e89..5dd2f32 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, }, + Stream { + id: String, + result: Result, + }, } const FIRST_PAGE: usize = 20; @@ -215,6 +219,8 @@ pub struct App { fail_streak: u8, want: Option, fetching: Option, + resolving: Option, + audio: Audio, result_cursor: Option, buffered: Vec, next_page: Option, @@ -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) { 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) { + 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) { diff --git a/src/config.rs b/src/config.rs index 07e67a5..afaf6ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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) -> 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")); + } } diff --git a/src/player.rs b/src/player.rs index 8b41a85..a4ea4b2 100644 --- a/src/player.rs +++ b/src/player.rs @@ -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 { .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id)) } -pub fn fetch_audio(id: &str) -> Result { +pub fn fetch_audio(id: &str, audio: &Audio) -> Result { 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 { .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 { + 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;