mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8da1876835 |
|||
| 5237a6bae8 |
5 changed files with 222 additions and 14 deletions
22
.github/workflows/rust.yml
vendored
Normal file
22
.github/workflows/rust.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
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 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) {
|
||||
|
|
|
|||
100
src/config.rs
100
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<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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue