diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 9fd45e0..0000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -1,22 +0,0 @@ -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 4ec6a77..550d231 100644 --- a/README.md +++ b/README.md @@ -169,16 +169,9 @@ 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 - -[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 +client_id = "" # change this for Discord RPC to work in Drift ``` -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 5dd2f32..1f49e89 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, Audio, Config}; +use crate::config::{self, Config}; use crate::lyrics::{self, Lyrics}; use crate::model::{split_artists, Collection, RepeatMode, Track}; use crate::discord::Discord; @@ -46,10 +46,6 @@ pub enum AppEvent { id: String, result: Result, }, - Stream { - id: String, - result: Result, - }, } const FIRST_PAGE: usize = 20; @@ -219,8 +215,6 @@ pub struct App { fail_streak: u8, want: Option, fetching: Option, - resolving: Option, - audio: Audio, result_cursor: Option, buffered: Vec, next_page: Option, @@ -321,8 +315,6 @@ impl App { fail_streak: 0, want: None, fetching: None, - resolving: None, - audio: cfg.audio.clone(), result_cursor: None, buffered: Vec::new(), next_page: None, @@ -1633,7 +1625,6 @@ 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); @@ -1763,53 +1754,17 @@ 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, &audio); + let result = player::fetch_audio(&id); let _ = tx.send(AppEvent::Audio { id, result }); }); } - fn load_track(&mut self, id: &str, duration: Option) { + 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 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)), + None => self.fetch_audio(id), } } @@ -1840,10 +1795,6 @@ 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); @@ -1871,7 +1822,7 @@ impl App { }; self.duration = track.duration.map_or(0.0, f64::from); self.info(format!("♪ {}", track.label())); - self.load_track(&track.id, track.duration); + self.load_track(&track.id); } fn play_default(&mut self) { @@ -1963,7 +1914,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, track.duration); + self.load_track(&track.id); } fn set_pause(&mut self, pause: bool) { diff --git a/src/config.rs b/src/config.rs index afaf6ee..07e67a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,45 +80,6 @@ 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 } @@ -143,7 +104,6 @@ pub struct Config { pub artist: Artist, pub ui: Ui, pub discord: Discord, - pub audio: Audio, } impl Config { @@ -151,7 +111,7 @@ impl Config { Ok(Store::dir()?.join("config.toml")) } - pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String { + pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord) -> String { let accent = match u.accent { Color::Reset => "reset", Color::Black => "black", @@ -203,19 +163,6 @@ 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, @@ -227,9 +174,6 @@ cookies_from_browser = \"{cookies}\" 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(['\\', '"'], ""), ) } @@ -244,7 +188,7 @@ cookies_from_browser = \"{cookies}\" 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(), &Audio::default())); + let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default())); return (Config::default(), None); } Err(e) => { @@ -323,7 +267,7 @@ pub fn migrate_legacy() { let _ = Session::write(&s); } } - let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); + let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default())); } #[cfg(test)] @@ -332,7 +276,7 @@ mod tests { #[test] fn default_file_parses_back() { - let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::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); @@ -399,42 +343,8 @@ mod tests { // Roundtrip: render then parse let u = Ui { accent: Color::Blue }; - let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default()); + 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); } - - #[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 a4ea4b2..8b41a85 100644 --- a/src/player.rs +++ b/src/player.rs @@ -6,8 +6,6 @@ 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)] @@ -313,15 +311,6 @@ 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) @@ -337,13 +326,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, audio: &Audio) -> Result { +pub fn fetch_audio(id: &str) -> 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 = ytdlp(audio); + let mut cmd = Command::new("yt-dlp"); cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"]) .arg("-P") .arg(&dir) @@ -368,35 +357,6 @@ pub fn fetch_audio(id: &str, audio: &Audio) -> 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;