mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
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.
This commit is contained in:
parent
f25b96d5fd
commit
5237a6bae8
4 changed files with 200 additions and 14 deletions
|
|
@ -170,8 +170,15 @@ accent = "cyan" # a color name or a hex value like "#FF8800"
|
||||||
|
|
||||||
[discord]
|
[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.
|
Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
|
||||||
61
src/app.rs
61
src/app.rs
|
|
@ -7,7 +7,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
|
|
||||||
use crate::command::{self, Amount, Command, COMMAND_NAMES};
|
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::lyrics::{self, Lyrics};
|
||||||
use crate::model::{split_artists, Collection, RepeatMode, Track};
|
use crate::model::{split_artists, Collection, RepeatMode, Track};
|
||||||
use crate::discord::Discord;
|
use crate::discord::Discord;
|
||||||
|
|
@ -46,6 +46,10 @@ pub enum AppEvent {
|
||||||
id: String,
|
id: String,
|
||||||
result: Result<std::path::PathBuf, String>,
|
result: Result<std::path::PathBuf, String>,
|
||||||
},
|
},
|
||||||
|
Stream {
|
||||||
|
id: String,
|
||||||
|
result: Result<String, String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIRST_PAGE: usize = 20;
|
const FIRST_PAGE: usize = 20;
|
||||||
|
|
@ -215,6 +219,8 @@ pub struct App {
|
||||||
fail_streak: u8,
|
fail_streak: u8,
|
||||||
want: Option<String>,
|
want: Option<String>,
|
||||||
fetching: Option<String>,
|
fetching: Option<String>,
|
||||||
|
resolving: Option<String>,
|
||||||
|
audio: Audio,
|
||||||
result_cursor: Option<usize>,
|
result_cursor: Option<usize>,
|
||||||
buffered: Vec<Track>,
|
buffered: Vec<Track>,
|
||||||
next_page: Option<String>,
|
next_page: Option<String>,
|
||||||
|
|
@ -315,6 +321,8 @@ impl App {
|
||||||
fail_streak: 0,
|
fail_streak: 0,
|
||||||
want: None,
|
want: None,
|
||||||
fetching: None,
|
fetching: None,
|
||||||
|
resolving: None,
|
||||||
|
audio: cfg.audio.clone(),
|
||||||
result_cursor: None,
|
result_cursor: None,
|
||||||
buffered: Vec::new(),
|
buffered: Vec::new(),
|
||||||
next_page: None,
|
next_page: None,
|
||||||
|
|
@ -1625,6 +1633,7 @@ impl App {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
AppEvent::Audio { id, result } => self.on_audio(id, result),
|
AppEvent::Audio { id, result } => self.on_audio(id, result),
|
||||||
|
AppEvent::Stream { id, result } => self.on_stream(id, result),
|
||||||
AppEvent::Player(generation, pe) => {
|
AppEvent::Player(generation, pe) => {
|
||||||
if generation == self.player_gen {
|
if generation == self.player_gen {
|
||||||
self.on_player(pe);
|
self.on_player(pe);
|
||||||
|
|
@ -1754,17 +1763,53 @@ impl App {
|
||||||
self.fetching = Some(id.to_string());
|
self.fetching = Some(id.to_string());
|
||||||
let tx = self.tx.clone();
|
let tx = self.tx.clone();
|
||||||
let id = id.to_string();
|
let id = id.to_string();
|
||||||
|
let audio = self.audio.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let result = player::fetch_audio(&id);
|
let result = player::fetch_audio(&id, &audio);
|
||||||
let _ = tx.send(AppEvent::Audio { id, result });
|
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());
|
self.want = Some(id.to_string());
|
||||||
match player::cached_audio(id) {
|
match player::cached_audio(id) {
|
||||||
Some(path) => self.on_audio(id.to_string(), Ok(path)),
|
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 {
|
let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else {
|
||||||
return;
|
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();
|
let id = next.id.clone();
|
||||||
if player::cached_audio(&id).is_none() {
|
if player::cached_audio(&id).is_none() {
|
||||||
self.fetch_audio(&id);
|
self.fetch_audio(&id);
|
||||||
|
|
@ -1822,7 +1871,7 @@ impl App {
|
||||||
};
|
};
|
||||||
self.duration = track.duration.map_or(0.0, f64::from);
|
self.duration = track.duration.map_or(0.0, f64::from);
|
||||||
self.info(format!("♪ {}", track.label()));
|
self.info(format!("♪ {}", track.label()));
|
||||||
self.load_track(&track.id);
|
self.load_track(&track.id, track.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play_default(&mut self) {
|
fn play_default(&mut self) {
|
||||||
|
|
@ -1914,7 +1963,7 @@ impl App {
|
||||||
self.position = 0.0;
|
self.position = 0.0;
|
||||||
self.duration = track.duration.map_or(0.0, f64::from);
|
self.duration = track.duration.map_or(0.0, f64::from);
|
||||||
self.info(format!("♪ {}", track.label()));
|
self.info(format!("♪ {}", track.label()));
|
||||||
self.load_track(&track.id);
|
self.load_track(&track.id, track.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_pause(&mut self, pause: bool) {
|
fn set_pause(&mut self, pause: bool) {
|
||||||
|
|
|
||||||
100
src/config.rs
100
src/config.rs
|
|
@ -80,6 +80,45 @@ pub struct Discord {
|
||||||
pub client_id: String,
|
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 {
|
fn default_accent() -> Color {
|
||||||
Color::Cyan
|
Color::Cyan
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +143,7 @@ pub struct Config {
|
||||||
pub artist: Artist,
|
pub artist: Artist,
|
||||||
pub ui: Ui,
|
pub ui: Ui,
|
||||||
pub discord: Discord,
|
pub discord: Discord,
|
||||||
|
pub audio: Audio,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|
@ -111,7 +151,7 @@ impl Config {
|
||||||
Ok(Store::dir()?.join("config.toml"))
|
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 {
|
let accent = match u.accent {
|
||||||
Color::Reset => "reset",
|
Color::Reset => "reset",
|
||||||
Color::Black => "black",
|
Color::Black => "black",
|
||||||
|
|
@ -163,6 +203,19 @@ accent = \"{accent}\"
|
||||||
# https://discord.com/developers/applications (its name is shown after
|
# https://discord.com/developers/applications (its name is shown after
|
||||||
# \"Listening to\") and paste its Application ID here. Empty means off.
|
# \"Listening to\") and paste its Application ID here. Empty means off.
|
||||||
client_id = \"{client_id}\"
|
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,
|
volume = p.volume,
|
||||||
shuffle = p.shuffle,
|
shuffle = p.shuffle,
|
||||||
|
|
@ -174,6 +227,9 @@ client_id = \"{client_id}\"
|
||||||
popular = a.popular_first,
|
popular = a.popular_first,
|
||||||
accent = accent,
|
accent = accent,
|
||||||
client_id = d.client_id,
|
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) {
|
let text = match fs::read_to_string(&path) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
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);
|
return (Config::default(), None);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -267,7 +323,7 @@ pub fn migrate_legacy() {
|
||||||
let _ = Session::write(&s);
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -276,7 +332,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_file_parses_back() {
|
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 c: Config = toml::from_str(&text).unwrap();
|
||||||
let d = Playback::default();
|
let d = Playback::default();
|
||||||
assert_eq!(c.playback.volume, d.volume);
|
assert_eq!(c.playback.volume, d.volume);
|
||||||
|
|
@ -343,8 +399,42 @@ mod tests {
|
||||||
|
|
||||||
// Roundtrip: render then parse
|
// Roundtrip: render then parse
|
||||||
let u = Ui { accent: Color::Blue };
|
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();
|
let c: Config = toml::from_str(&text).unwrap();
|
||||||
assert_eq!(c.ui.accent, Color::Blue);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ use std::time::{Duration, Instant};
|
||||||
use anyhow::{anyhow, bail, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::config::Audio;
|
||||||
|
|
||||||
/// Platform-specific transport for mpv's JSON IPC.
|
/// Platform-specific transport for mpv's JSON IPC.
|
||||||
/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`).
|
/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`).
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|
@ -311,6 +313,15 @@ impl Drop for Player {
|
||||||
|
|
||||||
const KEEP_FILES: usize = 40;
|
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 {
|
fn audio_dir() -> PathBuf {
|
||||||
dirs::cache_dir()
|
dirs::cache_dir()
|
||||||
.unwrap_or_else(std::env::temp_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))
|
.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) {
|
if let Some(path) = cached_audio(id) {
|
||||||
return Ok(path);
|
return Ok(path);
|
||||||
}
|
}
|
||||||
let dir = audio_dir();
|
let dir = audio_dir();
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
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"])
|
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"])
|
||||||
.arg("-P")
|
.arg("-P")
|
||||||
.arg(&dir)
|
.arg(&dir)
|
||||||
|
|
@ -357,6 +368,35 @@ pub fn fetch_audio(id: &str) -> Result<PathBuf, String> {
|
||||||
.unwrap_or_else(|| "yt-dlp downloaded nothing".into()))
|
.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) {
|
fn prune(dir: &std::path::Path) {
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue