mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
Add source code
This commit is contained in:
parent
c85d4b5c19
commit
6d386dfe9a
12 changed files with 4900 additions and 0 deletions
1358
Cargo.lock
generated
Normal file
1358
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "drift"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "A Vim-style terminal music player for YouTube Music (no login required)"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
dirs = "5"
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
1458
src/app.rs
Normal file
1458
src/app.rs
Normal file
File diff suppressed because it is too large
Load diff
199
src/command.rs
Normal file
199
src/command.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
use crate::model::{parse_clock, RepeatMode};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Amount {
|
||||
Abs(f64),
|
||||
Delta(f64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
Search(String),
|
||||
Play(Option<usize>),
|
||||
Pause,
|
||||
Resume,
|
||||
Toggle,
|
||||
Stop,
|
||||
Next,
|
||||
Prev,
|
||||
Quit,
|
||||
Add(Option<usize>),
|
||||
AddAll,
|
||||
PlayAll,
|
||||
Remove(Option<usize>),
|
||||
Clear,
|
||||
ShowResults,
|
||||
ShowQueue,
|
||||
ShowPlaylists,
|
||||
Volume(Amount),
|
||||
Seek(Amount),
|
||||
Shuffle,
|
||||
Repeat(Option<RepeatMode>),
|
||||
Save(String),
|
||||
Load(String),
|
||||
Append(String),
|
||||
DeletePlaylist(String),
|
||||
AddTo(String),
|
||||
Import(String),
|
||||
Artist,
|
||||
Help,
|
||||
}
|
||||
|
||||
pub const COMMAND_NAMES: &[&str] = &[
|
||||
"search", "play", "pause", "resume", "toggle", "stop", "next", "prev", "quit", "add",
|
||||
"addall", "playall", "remove", "clear", "results", "queue", "playlists", "volume", "seek",
|
||||
"shuffle", "repeat", "save", "load", "append", "delpl", "addto", "import", "artist", "help",
|
||||
];
|
||||
|
||||
fn unquote(s: &str) -> &str {
|
||||
let s = s.trim();
|
||||
let b = s.as_bytes();
|
||||
if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] {
|
||||
&s[1..s.len() - 1]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn index_arg(arg: &str, cmd: &str) -> Result<Option<usize>, String> {
|
||||
if arg.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
match arg.parse::<usize>() {
|
||||
Ok(n) if n >= 1 => Ok(Some(n)),
|
||||
_ => Err(format!(":{cmd} expects a row number (1, 2, 3 …), got '{arg}'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_arg(arg: &str, cmd: &str) -> Result<String, String> {
|
||||
let a = unquote(arg);
|
||||
if a.is_empty() {
|
||||
Err(format!("usage: :{cmd} <name>"))
|
||||
} else {
|
||||
Ok(a.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_amount(arg: &str, cmd: &str) -> Result<Amount, String> {
|
||||
let a = arg.trim().trim_end_matches('%');
|
||||
if a.is_empty() {
|
||||
return Err(format!("usage: :{cmd} <value> (e.g. 50, +10, -10)"));
|
||||
}
|
||||
let err = || format!(":{cmd}: cannot understand '{arg}'");
|
||||
if let Some(rest) = a.strip_prefix('+') {
|
||||
Ok(Amount::Delta(parse_clock(rest).ok_or_else(err)?))
|
||||
} else if let Some(rest) = a.strip_prefix('-') {
|
||||
Ok(Amount::Delta(-parse_clock(rest).ok_or_else(err)?))
|
||||
} else {
|
||||
Ok(Amount::Abs(parse_clock(a).ok_or_else(err)?))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(line: &str) -> Result<Option<Command>, String> {
|
||||
let line = line.trim().trim_start_matches(':').trim();
|
||||
if line.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let end = line
|
||||
.find(|c: char| !(c.is_ascii_alphabetic() || c == '!'))
|
||||
.unwrap_or(line.len());
|
||||
let (name, rest) = line.split_at(end);
|
||||
let rest = rest.trim();
|
||||
let arg = unquote(rest);
|
||||
|
||||
use Command::*;
|
||||
let cmd = match name.to_ascii_lowercase().as_str() {
|
||||
"search" | "s" | "find" => {
|
||||
if arg.is_empty() {
|
||||
return Err("usage: :search \"your query\"".into());
|
||||
}
|
||||
Search(arg.to_string())
|
||||
}
|
||||
"play" => Play(index_arg(arg, "play")?),
|
||||
"pause" => Pause,
|
||||
"resume" | "unpause" => Resume,
|
||||
"toggle" => Toggle,
|
||||
"stop" => Stop,
|
||||
"next" | "n" => Next,
|
||||
"prev" | "previous" => Prev,
|
||||
"quit" | "q" | "exit" | "q!" | "qa" | "wq" => Quit,
|
||||
"add" | "a" => Add(index_arg(arg, "add")?),
|
||||
"addall" => AddAll,
|
||||
"playall" => PlayAll,
|
||||
"remove" | "rm" | "del" => Remove(index_arg(arg, "remove")?),
|
||||
"clear" => Clear,
|
||||
"results" | "res" => ShowResults,
|
||||
"queue" => ShowQueue,
|
||||
"playlists" | "pls" => ShowPlaylists,
|
||||
"volume" | "vol" | "v" => Volume(parse_amount(arg, "volume")?),
|
||||
"seek" => Seek(parse_amount(arg, "seek")?),
|
||||
"shuffle" => Shuffle,
|
||||
"repeat" => match arg.to_ascii_lowercase().as_str() {
|
||||
"" => Repeat(None),
|
||||
"off" | "none" | "no" => Repeat(Some(RepeatMode::Off)),
|
||||
"all" | "queue" => Repeat(Some(RepeatMode::All)),
|
||||
"one" | "track" | "1" => Repeat(Some(RepeatMode::One)),
|
||||
other => return Err(format!(":repeat expects off|all|one, got '{other}'")),
|
||||
},
|
||||
"save" | "w" => Save(name_arg(arg, "save")?),
|
||||
"load" | "e" => Load(name_arg(arg, "load")?),
|
||||
"append" => Append(name_arg(arg, "append")?),
|
||||
"delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?),
|
||||
"addto" => AddTo(name_arg(arg, "addto")?),
|
||||
"import" => {
|
||||
if arg.is_empty() {
|
||||
return Err("usage: :import <youtube / youtube music playlist URL>".into());
|
||||
}
|
||||
Import(arg.to_string())
|
||||
}
|
||||
"artist" | "ar" => Artist,
|
||||
"help" | "h" => Help,
|
||||
other => return Err(format!("unknown command: :{other} (try :help)")),
|
||||
};
|
||||
Ok(Some(cmd))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn search_variants() {
|
||||
assert_eq!(parse(r#"search "daft punk""#), Ok(Some(Command::Search("daft punk".into()))));
|
||||
assert_eq!(parse(":search daft punk"), Ok(Some(Command::Search("daft punk".into()))));
|
||||
assert_eq!(parse(r#"s"lofi beats""#), Ok(Some(Command::Search("lofi beats".into()))));
|
||||
assert!(parse("search").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_commands() {
|
||||
assert_eq!(parse("play"), Ok(Some(Command::Play(None))));
|
||||
assert_eq!(parse("play 3"), Ok(Some(Command::Play(Some(3)))));
|
||||
assert!(parse("play 0").is_err());
|
||||
assert_eq!(parse("q!"), Ok(Some(Command::Quit)));
|
||||
assert_eq!(parse("pause"), Ok(Some(Command::Pause)));
|
||||
assert_eq!(parse(" "), Ok(None));
|
||||
assert!(parse("bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amounts() {
|
||||
assert_eq!(parse("vol 50"), Ok(Some(Command::Volume(Amount::Abs(50.0)))));
|
||||
assert_eq!(parse("vol -10"), Ok(Some(Command::Volume(Amount::Delta(-10.0)))));
|
||||
assert_eq!(parse("seek 1:30"), Ok(Some(Command::Seek(Amount::Abs(90.0)))));
|
||||
assert_eq!(parse("seek +15"), Ok(Some(Command::Seek(Amount::Delta(15.0)))));
|
||||
assert!(parse("vol loud").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_command() {
|
||||
assert_eq!(parse("artist"), Ok(Some(Command::Artist)));
|
||||
assert_eq!(parse(":ar"), Ok(Some(Command::Artist)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlists() {
|
||||
assert_eq!(parse(r#"save "my mix""#), Ok(Some(Command::Save("my mix".into()))));
|
||||
assert!(parse("save").is_err());
|
||||
}
|
||||
}
|
||||
204
src/config.rs
Normal file
204
src/config.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::model::{RepeatMode, Track};
|
||||
use crate::session::Session;
|
||||
use crate::store::Store;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Playback {
|
||||
pub volume: u8,
|
||||
pub shuffle: bool,
|
||||
pub repeat: RepeatMode,
|
||||
pub autoplay: bool,
|
||||
pub restore_session: bool,
|
||||
pub seek_step: f64,
|
||||
pub volume_step: f64,
|
||||
}
|
||||
|
||||
impl Default for Playback {
|
||||
fn default() -> Self {
|
||||
Playback {
|
||||
volume: 80,
|
||||
shuffle: false,
|
||||
repeat: RepeatMode::Off,
|
||||
autoplay: false,
|
||||
restore_session: true,
|
||||
seek_step: 5.0,
|
||||
volume_step: 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
fn sanitize(&mut self) {
|
||||
self.volume = self.volume.min(100);
|
||||
let ok = |v: f64| v.is_finite() && v > 0.0;
|
||||
if !ok(self.seek_step) {
|
||||
self.seek_step = 5.0;
|
||||
}
|
||||
if !ok(self.volume_step) {
|
||||
self.volume_step = 5.0;
|
||||
}
|
||||
self.volume_step = self.volume_step.min(100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub playback: Playback,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn path() -> Result<PathBuf> {
|
||||
Ok(Store::path()?.with_file_name("config.toml"))
|
||||
}
|
||||
|
||||
pub fn render(p: &Playback) -> String {
|
||||
format!(
|
||||
"\
|
||||
# Drift settings. Drift only read this file and never rewrites it, so your
|
||||
# comments are safe. Restart drift after editing
|
||||
|
||||
[playback]
|
||||
volume = {volume}
|
||||
shuffle = {shuffle}
|
||||
repeat = \"{repeat}\"
|
||||
autoplay = {autoplay}
|
||||
restore_session = {restore}
|
||||
seek_step = {seek:?}
|
||||
volume_step = {vstep:?}
|
||||
",
|
||||
volume = p.volume,
|
||||
shuffle = p.shuffle,
|
||||
repeat = p.repeat.label(),
|
||||
autoplay = p.autoplay,
|
||||
restore = p.restore_session,
|
||||
seek = p.seek_step,
|
||||
vstep = p.volume_step,
|
||||
)
|
||||
}
|
||||
|
||||
fn write(text: &str) -> Result<()> {
|
||||
let path = Self::path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load() -> (Config, Option<String>) {
|
||||
let Ok(path) = Self::path() else {
|
||||
return (Config::default(), None);
|
||||
};
|
||||
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()));
|
||||
return (Config::default(), None);
|
||||
}
|
||||
Err(e) => {
|
||||
return (Config::default(), Some(format!("cannot read {}: {e}", path.display())))
|
||||
}
|
||||
};
|
||||
match toml::from_str::<Config>(&text) {
|
||||
Ok(mut c) => {
|
||||
c.playback.sanitize();
|
||||
(c, None)
|
||||
}
|
||||
Err(e) => {
|
||||
let bak = path.with_extension("toml.bak");
|
||||
let _ = fs::rename(&path, &bak);
|
||||
(
|
||||
Config::default(),
|
||||
Some(format!(
|
||||
"config.toml was invalid ({}); moved to {}",
|
||||
e.message(),
|
||||
bak.display()
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn migrate_legacy() {
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct Legacy {
|
||||
volume: Option<u8>,
|
||||
shuffle: Option<bool>,
|
||||
repeat: Option<RepeatMode>,
|
||||
autoplay: Option<bool>,
|
||||
current: Option<usize>,
|
||||
position: f64,
|
||||
queue: Vec<Track>,
|
||||
}
|
||||
|
||||
let Ok(path) = Config::path() else { return };
|
||||
let Ok(text) = fs::read_to_string(&path) else { return };
|
||||
let Ok(table) = text.parse::<toml::Table>() else { return };
|
||||
if table.contains_key("playback") || !(table.contains_key("queue") || table.contains_key("position")) {
|
||||
return;
|
||||
}
|
||||
let Ok(old) = toml::from_str::<Legacy>(&text) else { return };
|
||||
|
||||
let d = Playback::default();
|
||||
let mut p = Playback {
|
||||
volume: old.volume.unwrap_or(d.volume),
|
||||
shuffle: old.shuffle.unwrap_or(d.shuffle),
|
||||
repeat: old.repeat.unwrap_or(d.repeat),
|
||||
autoplay: old.autoplay.unwrap_or(d.autoplay),
|
||||
..d
|
||||
};
|
||||
p.sanitize();
|
||||
let session = Session { queue: old.queue, current: old.current, position: old.position };
|
||||
if let Ok(s) = session.render() {
|
||||
if !Session::exists() {
|
||||
let _ = Session::write(&s);
|
||||
}
|
||||
}
|
||||
let _ = Config::write(&Config::render(&p));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_file_parses_back() {
|
||||
let text = Config::render(&Playback::default());
|
||||
let c: Config = toml::from_str(&text).unwrap();
|
||||
let d = Playback::default();
|
||||
assert_eq!(c.playback.volume, d.volume);
|
||||
assert_eq!(c.playback.repeat, d.repeat);
|
||||
assert_eq!(c.playback.seek_step, 5.0);
|
||||
assert!(c.playback.restore_session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_and_empty_files() {
|
||||
let c: Config = toml::from_str("").unwrap();
|
||||
assert_eq!(c.playback.volume, 80);
|
||||
let c: Config =
|
||||
toml::from_str("[playback]\nvolume = 10\nrepeat = \"one\"\nseek_step = 10").unwrap();
|
||||
assert_eq!((c.playback.volume, c.playback.repeat), (10, RepeatMode::One));
|
||||
assert_eq!(c.playback.seek_step, 10.0);
|
||||
assert!(toml::from_str::<Config>("[playback]\nrepeat = \"sometimes\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_bad_values() {
|
||||
let mut p = Playback { volume: 250, seek_step: -3.0, volume_step: 900.0, ..Playback::default() };
|
||||
p.sanitize();
|
||||
assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0));
|
||||
}
|
||||
}
|
||||
151
src/main.rs
Normal file
151
src/main.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
mod app;
|
||||
mod command;
|
||||
mod config;
|
||||
mod model;
|
||||
mod player;
|
||||
mod session;
|
||||
mod store;
|
||||
mod ui;
|
||||
mod ytmusic;
|
||||
|
||||
use std::process::{Command, ExitCode, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use ratatui::crossterm::event::{self, Event};
|
||||
use ratatui::DefaultTerminal;
|
||||
|
||||
use app::{App, AppEvent};
|
||||
|
||||
const USAGE: &str = "\
|
||||
drift — Vim-style terminal player for YouTube Music (no login needed)
|
||||
|
||||
USAGE:
|
||||
drift [QUERY...] start the player, optionally running a search first
|
||||
drift --check verify that mpv and yt-dlp are installed
|
||||
drift --help show this help
|
||||
drift --version
|
||||
|
||||
Inside the player press ? for key bindings and :help for commands.";
|
||||
|
||||
fn tool_version(name: &str) -> Option<String> {
|
||||
let out = Command::new(name)
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
out.status.success().then(|| {
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_dependencies(verbose: bool) -> Vec<String> {
|
||||
let mut missing = Vec::new();
|
||||
for (tool, hint) in [
|
||||
("mpv", "sudo apt install mpv | brew install mpv | sudo pacman -S mpv"),
|
||||
("yt-dlp", "pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp"),
|
||||
] {
|
||||
match tool_version(tool) {
|
||||
Some(v) => {
|
||||
if verbose {
|
||||
println!("ok {tool}: {v}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if verbose {
|
||||
println!("MISSING {tool}");
|
||||
}
|
||||
missing.push(format!(" {tool:<7} not found. Install: {hint}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(String::as_str) {
|
||||
Some("-h") | Some("--help") => {
|
||||
println!("{USAGE}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
Some("-V") | Some("--version") => {
|
||||
println!("drift {}", env!("CARGO_PKG_VERSION"));
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
Some("--check") => {
|
||||
let missing = missing_dependencies(true);
|
||||
return if missing.is_empty() {
|
||||
println!("All dependencies found.");
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
for m in &missing {
|
||||
eprintln!("{m}");
|
||||
}
|
||||
ExitCode::FAILURE
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let missing = missing_dependencies(false);
|
||||
if !missing.is_empty() {
|
||||
eprintln!("drift needs these programs to search and play music:\n");
|
||||
for m in &missing {
|
||||
eprintln!("{m}");
|
||||
}
|
||||
eprintln!("\nSee the README for details.");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let initial = (!args.is_empty()).then(|| args.join(" "));
|
||||
match run(initial) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(initial_query: Option<String>) -> Result<()> {
|
||||
let (tx, rx) = mpsc::channel::<AppEvent>();
|
||||
let mut app = App::new(tx);
|
||||
if let Some(q) = initial_query {
|
||||
app.run_command(&format!("search {q}"));
|
||||
}
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
let result = event_loop(&mut terminal, &mut app, &rx);
|
||||
ratatui::restore();
|
||||
app.shutdown();
|
||||
result
|
||||
}
|
||||
|
||||
fn event_loop(
|
||||
terminal: &mut DefaultTerminal,
|
||||
app: &mut App,
|
||||
rx: &mpsc::Receiver<AppEvent>,
|
||||
) -> Result<()> {
|
||||
while !app.should_quit {
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.on_key(key);
|
||||
}
|
||||
}
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
app.on_event(ev);
|
||||
}
|
||||
app.on_tick();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
129
src/model.rs
Normal file
129
src/model.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Track {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
#[serde(default)]
|
||||
pub album: Option<String>,
|
||||
#[serde(default)]
|
||||
pub duration: Option<u32>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn url(&self) -> String {
|
||||
format!("https://music.youtube.com/watch?v={}", self.id)
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
format!("{} — {}", self.title, self.artist)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split_artists(credit: &str) -> Vec<String> {
|
||||
const MARK: char = '\u{1}';
|
||||
let mut s = credit.to_string();
|
||||
for sep in [" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × "] {
|
||||
s = s.replace(sep, &MARK.to_string());
|
||||
}
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for part in s.split(MARK) {
|
||||
let name = part.trim();
|
||||
if !name.is_empty() && !out.iter().any(|o| o == name) {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_valid_id(id: &str) -> bool {
|
||||
id.len() == 11
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
pub fn parse_clock(s: &str) -> Option<f64> {
|
||||
let s = s.trim().trim_end_matches('s');
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut total = 0.0;
|
||||
for part in s.split(':') {
|
||||
let v: f64 = part.trim().parse().ok()?;
|
||||
if v < 0.0 {
|
||||
return None;
|
||||
}
|
||||
total = total * 60.0 + v;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
pub fn fmt_time(secs: f64) -> String {
|
||||
let s = if secs.is_finite() { secs.max(0.0) as u64 } else { 0 };
|
||||
let (h, m, s) = (s / 3600, (s % 3600) / 60, s % 60);
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RepeatMode {
|
||||
Off,
|
||||
All,
|
||||
One,
|
||||
}
|
||||
|
||||
impl RepeatMode {
|
||||
pub fn cycle(self) -> Self {
|
||||
match self {
|
||||
RepeatMode::Off => RepeatMode::All,
|
||||
RepeatMode::All => RepeatMode::One,
|
||||
RepeatMode::One => RepeatMode::Off,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
RepeatMode::Off => "off",
|
||||
RepeatMode::All => "all",
|
||||
RepeatMode::One => "one",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clock_parsing() {
|
||||
assert_eq!(parse_clock("3:45"), Some(225.0));
|
||||
assert_eq!(parse_clock("1:02:03"), Some(3723.0));
|
||||
assert_eq!(parse_clock("90"), Some(5400.0 / 60.0));
|
||||
assert_eq!(parse_clock("abc"), None);
|
||||
assert_eq!(fmt_time(225.0), "3:45");
|
||||
assert_eq!(fmt_time(3723.0), "1:02:03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_splitting() {
|
||||
assert_eq!(split_artists("Daft Punk"), ["Daft Punk"]);
|
||||
assert_eq!(split_artists("A & B"), ["A", "B"]);
|
||||
assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]);
|
||||
assert_eq!(split_artists("A feat. B"), ["A", "B"]);
|
||||
assert_eq!(split_artists("A, A"), ["A"]);
|
||||
assert!(split_artists(" ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids() {
|
||||
assert!(is_valid_id("dQw4w9WgXcQ"));
|
||||
assert!(!is_valid_id("short"));
|
||||
assert!(!is_valid_id("dQw4w9WgXc&"));
|
||||
}
|
||||
}
|
||||
268
src/player.rs
Normal file
268
src/player.rs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PlayerEvent {
|
||||
Position(Option<f64>),
|
||||
Duration(Option<f64>),
|
||||
Paused(bool),
|
||||
Volume(f64),
|
||||
FileLoaded,
|
||||
EndOfFile,
|
||||
LoadError(String),
|
||||
Died,
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
child: Child,
|
||||
writer: UnixStream,
|
||||
sock_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn spawn(
|
||||
volume: u8,
|
||||
on_event: impl Fn(PlayerEvent) + Send + 'static,
|
||||
) -> Result<Player> {
|
||||
let sock_path =
|
||||
std::env::temp_dir().join(format!("drift-{}.sock", std::process::id()));
|
||||
let _ = std::fs::remove_file(&sock_path);
|
||||
|
||||
let mut cmd = Command::new("mpv");
|
||||
cmd.args([
|
||||
"--no-config",
|
||||
"--idle=yes",
|
||||
"--no-video",
|
||||
"--no-terminal",
|
||||
"--really-quiet",
|
||||
"--audio-display=no",
|
||||
"--force-window=no",
|
||||
"--ytdl=yes",
|
||||
"--ytdl-format=bestaudio/best",
|
||||
"--cache=yes",
|
||||
"--demuxer-max-bytes=32MiB",
|
||||
])
|
||||
.arg(format!("--volume={volume}"))
|
||||
.arg(format!("--input-ipc-server={}", sock_path.display()));
|
||||
|
||||
if let Ok(extra) = std::env::var("DRIFT_MPV_ARGS").or_else(|_| std::env::var("YTM_MPV_ARGS")) {
|
||||
cmd.args(extra.split_whitespace());
|
||||
}
|
||||
cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.pre_exec(|| {
|
||||
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
anyhow!("mpv not found — install it (see README)")
|
||||
} else {
|
||||
anyhow!("failed to start mpv: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let start = Instant::now();
|
||||
let stream = loop {
|
||||
match UnixStream::connect(&sock_path) {
|
||||
Ok(s) => break s,
|
||||
Err(e) => {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
bail!("mpv exited immediately ({status})");
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(6) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
bail!("timed out connecting to mpv IPC socket: {e}");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let reader = stream.try_clone().context("cloning mpv socket")?;
|
||||
let mut player = Player {
|
||||
child,
|
||||
writer: stream,
|
||||
sock_path,
|
||||
};
|
||||
|
||||
for (id, prop) in [(1, "time-pos"), (2, "duration"), (3, "pause"), (4, "volume")] {
|
||||
player.send(json!(["observe_property", id, prop]))?;
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(reader).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(v) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if let Some(ev) = parse_event(&v) {
|
||||
on_event(ev);
|
||||
}
|
||||
}
|
||||
on_event(PlayerEvent::Died);
|
||||
});
|
||||
|
||||
Ok(player)
|
||||
}
|
||||
|
||||
fn send(&mut self, command: Value) -> Result<()> {
|
||||
let mut line = serde_json::to_string(&json!({ "command": command }))?;
|
||||
line.push('\n');
|
||||
self.writer
|
||||
.write_all(line.as_bytes())
|
||||
.and_then(|_| self.writer.flush())
|
||||
.map_err(|e| anyhow!("lost connection to mpv: {e}"))
|
||||
}
|
||||
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(None))
|
||||
}
|
||||
|
||||
pub fn load(&mut self, url: &str) -> Result<()> {
|
||||
self.send(json!(["set_property", "pause", false]))?;
|
||||
self.send(json!(["loadfile", url, "replace"]))
|
||||
}
|
||||
|
||||
pub fn set_pause(&mut self, pause: bool) -> Result<()> {
|
||||
self.send(json!(["set_property", "pause", pause]))
|
||||
}
|
||||
|
||||
pub fn set_volume(&mut self, vol: u8) -> Result<()> {
|
||||
self.send(json!(["set_property", "volume", vol]))
|
||||
}
|
||||
|
||||
pub fn seek_relative(&mut self, secs: f64) -> Result<()> {
|
||||
self.send(json!(["seek", secs, "relative"]))
|
||||
}
|
||||
|
||||
pub fn seek_absolute(&mut self, secs: f64) -> Result<()> {
|
||||
self.send(json!(["seek", secs, "absolute"]))
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) -> Result<()> {
|
||||
self.send(json!(["stop"]))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Player {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.send(json!(["quit"]));
|
||||
for _ in 0..10 {
|
||||
if matches!(self.child.try_wait(), Ok(Some(_))) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
let _ = std::fs::remove_file(&self.sock_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_event(v: &Value) -> Option<PlayerEvent> {
|
||||
match v.get("event")?.as_str()? {
|
||||
"property-change" => {
|
||||
let data = v.get("data");
|
||||
match v.get("name")?.as_str()? {
|
||||
"time-pos" => Some(PlayerEvent::Position(data.and_then(Value::as_f64))),
|
||||
"duration" => Some(PlayerEvent::Duration(data.and_then(Value::as_f64))),
|
||||
"pause" => Some(PlayerEvent::Paused(
|
||||
data.and_then(Value::as_bool).unwrap_or(false),
|
||||
)),
|
||||
"volume" => data.and_then(Value::as_f64).map(PlayerEvent::Volume),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"file-loaded" => Some(PlayerEvent::FileLoaded),
|
||||
"end-file" => match v.get("reason").and_then(Value::as_str) {
|
||||
Some("eof") => Some(PlayerEvent::EndOfFile),
|
||||
Some("error") => Some(PlayerEvent::LoadError(
|
||||
v.get("file_error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("playback failed")
|
||||
.to_string(),
|
||||
)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn mpv_roundtrip() {
|
||||
use std::sync::mpsc;
|
||||
let wav = std::env::temp_dir().join("drift-test.wav");
|
||||
let samples: Vec<u8> = (0..16_000u32).map(|i| if (i / 20) % 2 == 0 { 140 } else { 116 }).collect();
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(b"RIFF");
|
||||
data.extend_from_slice(&(36 + samples.len() as u32).to_le_bytes());
|
||||
data.extend_from_slice(b"WAVEfmt ");
|
||||
data.extend_from_slice(&16u32.to_le_bytes());
|
||||
data.extend_from_slice(&1u16.to_le_bytes());
|
||||
data.extend_from_slice(&1u16.to_le_bytes());
|
||||
data.extend_from_slice(&8000u32.to_le_bytes());
|
||||
data.extend_from_slice(&8000u32.to_le_bytes());
|
||||
data.extend_from_slice(&1u16.to_le_bytes());
|
||||
data.extend_from_slice(&8u16.to_le_bytes());
|
||||
data.extend_from_slice(b"data");
|
||||
data.extend_from_slice(&(samples.len() as u32).to_le_bytes());
|
||||
data.extend_from_slice(&samples);
|
||||
std::fs::write(&wav, data).unwrap();
|
||||
|
||||
std::env::set_var("DRIFT_MPV_ARGS", "--ao=null");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut p = Player::spawn(50, move |e| {
|
||||
let _ = tx.send(e);
|
||||
})
|
||||
.expect("spawn mpv");
|
||||
p.load(wav.to_str().unwrap()).unwrap();
|
||||
let (mut loaded, mut eof) = (false, false);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline && !eof {
|
||||
match rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(PlayerEvent::FileLoaded) => loaded = true,
|
||||
Ok(PlayerEvent::EndOfFile) => eof = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(loaded, "file-loaded not seen");
|
||||
assert!(eof, "end-of-file not seen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_parsing() {
|
||||
let v: Value = serde_json::from_str(
|
||||
r#"{"event":"property-change","id":1,"name":"time-pos","data":12.5}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(parse_event(&v), Some(PlayerEvent::Position(Some(p))) if p == 12.5));
|
||||
|
||||
let v: Value = serde_json::from_str(r#"{"event":"end-file","reason":"eof"}"#).unwrap();
|
||||
assert!(matches!(parse_event(&v), Some(PlayerEvent::EndOfFile)));
|
||||
|
||||
let v: Value = serde_json::from_str(r#"{"event":"end-file","reason":"stop"}"#).unwrap();
|
||||
assert!(parse_event(&v).is_none());
|
||||
|
||||
let v: Value = serde_json::from_str(r#"{"error":"success","request_id":0}"#).unwrap();
|
||||
assert!(parse_event(&v).is_none());
|
||||
}
|
||||
}
|
||||
98
src/session.rs
Normal file
98
src/session.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::{is_valid_id, Track};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Session {
|
||||
pub queue: Vec<Track>,
|
||||
pub current: Option<usize>,
|
||||
pub position: f64,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn path() -> Result<PathBuf> {
|
||||
let dir = dirs::state_dir()
|
||||
.or_else(dirs::data_local_dir)
|
||||
.ok_or_else(|| anyhow!("cannot determine state directory"))?;
|
||||
Ok(dir.join("drift").join("session.json"))
|
||||
}
|
||||
|
||||
pub fn exists() -> bool {
|
||||
Self::path().is_ok_and(|p| p.exists())
|
||||
}
|
||||
|
||||
pub fn load() -> Session {
|
||||
let Ok(path) = Self::path() else { return Session::default() };
|
||||
let Ok(text) = fs::read_to_string(&path) else { return Session::default() };
|
||||
match serde_json::from_str::<Session>(&text) {
|
||||
Ok(mut s) => {
|
||||
s.sanitize();
|
||||
s
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = fs::rename(&path, path.with_extension("json.bak"));
|
||||
Session::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize(&mut self) {
|
||||
self.queue.retain(|t| is_valid_id(&t.id));
|
||||
if !self.position.is_finite() || self.position < 0.0 {
|
||||
self.position = 0.0;
|
||||
}
|
||||
match self.current {
|
||||
Some(_) if self.queue.is_empty() => self.current = None,
|
||||
Some(c) if c >= self.queue.len() => self.current = Some(self.queue.len() - 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self) -> Result<String> {
|
||||
Ok(serde_json::to_string_pretty(self)?)
|
||||
}
|
||||
|
||||
pub fn write(text: &str) -> Result<()> {
|
||||
let path = Self::path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track(id: &str) -> Track {
|
||||
Track { id: id.into(), title: "T".into(), artist: "A".into(), album: None, duration: Some(200) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_and_sanitize() {
|
||||
let s = Session {
|
||||
queue: vec![track("aaaaaaaaaaa"), track("bad")],
|
||||
current: Some(7),
|
||||
position: f64::NAN,
|
||||
};
|
||||
let mut back: Session = serde_json::from_str(&s.render().unwrap().replace("NaN", "null")).unwrap_or_default();
|
||||
back.queue = s.queue.clone();
|
||||
back.current = s.current;
|
||||
back.position = s.position;
|
||||
back.sanitize();
|
||||
assert_eq!((back.queue.len(), back.current, back.position), (1, Some(0), 0.0));
|
||||
|
||||
let ok = Session { queue: vec![track("aaaaaaaaaaa")], current: Some(0), position: 42.5 };
|
||||
let again: Session = serde_json::from_str(&ok.render().unwrap()).unwrap();
|
||||
assert_eq!((again.current, again.position, again.queue), (Some(0), 42.5, ok.queue));
|
||||
}
|
||||
}
|
||||
92
src/store.rs
Normal file
92
src/store.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::Track;
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Store {
|
||||
pub playlists: BTreeMap<String, Vec<Track>>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn path() -> Result<PathBuf> {
|
||||
let dir = dirs::config_dir().ok_or_else(|| anyhow!("cannot determine config directory"))?;
|
||||
let new_dir = dir.join("drift");
|
||||
let old_dir = dir.join("ytm-tui");
|
||||
if !new_dir.exists() && old_dir.exists() {
|
||||
let _ = fs::rename(&old_dir, &new_dir);
|
||||
}
|
||||
Ok(new_dir.join("playlists.json"))
|
||||
}
|
||||
|
||||
pub fn load() -> (Store, Option<String>) {
|
||||
let path = match Self::path() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return (Store::default(), Some(format!("playlists disabled: {e}"))),
|
||||
};
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Store::default(), None),
|
||||
Err(e) => {
|
||||
return (
|
||||
Store::default(),
|
||||
Some(format!("cannot read {}: {e}", path.display())),
|
||||
)
|
||||
}
|
||||
};
|
||||
match serde_json::from_str::<Store>(&text) {
|
||||
Ok(s) => (s, None),
|
||||
Err(e) => {
|
||||
let bak = path.with_extension("json.bak");
|
||||
let _ = fs::rename(&path, &bak);
|
||||
(
|
||||
Store::default(),
|
||||
Some(format!(
|
||||
"playlists file was corrupt ({e}); moved to {}",
|
||||
bak.display()
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let json = serde_json::to_string_pretty(self)?;
|
||||
fs::write(&tmp, json).with_context(|| format!("writing {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve(&self, name: &str) -> Option<String> {
|
||||
if self.playlists.contains_key(name) {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
let lower = name.to_lowercase();
|
||||
let ci: Vec<&String> = self
|
||||
.playlists
|
||||
.keys()
|
||||
.filter(|k| k.to_lowercase() == lower)
|
||||
.collect();
|
||||
if ci.len() == 1 {
|
||||
return Some(ci[0].clone());
|
||||
}
|
||||
let pre: Vec<&String> = self
|
||||
.playlists
|
||||
.keys()
|
||||
.filter(|k| k.to_lowercase().starts_with(&lower))
|
||||
.collect();
|
||||
if pre.len() == 1 {
|
||||
return Some(pre[0].clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
602
src/ui.rs
Normal file
602
src/ui.rs
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style, Stylize};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, Tabs};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::{App, Level, Mode, PlayState, View};
|
||||
use crate::model::{fmt_time, Track};
|
||||
|
||||
const ACCENT: Color = Color::Cyan;
|
||||
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
fn selected_style() -> Style {
|
||||
Style::new().fg(Color::Black).bg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn header_style() -> Style {
|
||||
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn dim() -> Style {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
}
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||
let area = f.area();
|
||||
let [top, body, playing, bottom] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
draw_tabs(f, app, top);
|
||||
match app.view {
|
||||
View::Results => draw_tracks(f, app, body, View::Results),
|
||||
View::Queue => draw_tracks(f, app, body, View::Queue),
|
||||
View::Playlists => draw_playlists(f, app, body),
|
||||
}
|
||||
draw_now_playing(f, app, playing);
|
||||
draw_cmdline(f, app, bottom);
|
||||
if app.mode == Mode::Help {
|
||||
draw_help(f, app, area);
|
||||
}
|
||||
if app.mode == Mode::ArtistPick {
|
||||
draw_artist_picker(f, app, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
|
||||
let [name, tabs, right] = Layout::horizontal([
|
||||
Constraint::Length(9),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(34),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
f.render_widget(Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), name);
|
||||
|
||||
let titles = [
|
||||
format!(" Results {} ", app.results.len()),
|
||||
format!(" Queue {} ", app.queue.len()),
|
||||
format!(" Playlists {} ", app.store.playlists.len()),
|
||||
];
|
||||
f.render_widget(
|
||||
Tabs::new(titles)
|
||||
.select(app.view.idx())
|
||||
.style(Style::new().fg(Color::Gray))
|
||||
.highlight_style(Style::new().fg(Color::Black).bg(ACCENT).bold())
|
||||
.divider(" "),
|
||||
tabs,
|
||||
);
|
||||
|
||||
if let Some(busy) = &app.busy {
|
||||
let spin = SPINNER[app.tick % SPINNER.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("{spin} {busy}… "))
|
||||
.alignment(Alignment::Right)
|
||||
.style(Style::new().fg(Color::Yellow)),
|
||||
right,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn track_rows<'a>(
|
||||
tracks: &'a [Track],
|
||||
playing: Option<(&str, &'a str)>,
|
||||
show_album: bool,
|
||||
) -> Vec<Row<'a>> {
|
||||
tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let is_playing = playing.is_some_and(|(id, _)| id == t.id);
|
||||
let marker = if is_playing { playing.map_or("", |p| p.1) } else { "" };
|
||||
let mut cells = vec![
|
||||
Cell::from(marker),
|
||||
Cell::from(format!("{:>3}", i + 1)).style(dim()),
|
||||
Cell::from(t.title.as_str()),
|
||||
Cell::from(t.artist.as_str()),
|
||||
];
|
||||
if show_album {
|
||||
cells.push(Cell::from(t.album.as_deref().unwrap_or("")));
|
||||
}
|
||||
cells.push(
|
||||
Cell::from(t.duration.map_or(String::new(), |d| fmt_time(d as f64)))
|
||||
.style(dim()),
|
||||
);
|
||||
let row = Row::new(cells);
|
||||
if is_playing {
|
||||
row.style(Style::new().fg(Color::Green).add_modifier(Modifier::BOLD))
|
||||
} else {
|
||||
row
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn track_table<'a>(rows: Vec<Row<'a>>, show_album: bool, block: Block<'a>) -> Table<'a> {
|
||||
let mut header = vec!["", " #", "Title", "Artist"];
|
||||
let mut widths = vec![
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(4),
|
||||
Constraint::Fill(3),
|
||||
Constraint::Fill(2),
|
||||
];
|
||||
if show_album {
|
||||
header.push("Album");
|
||||
widths.push(Constraint::Fill(2));
|
||||
}
|
||||
header.push("Time");
|
||||
widths.push(Constraint::Length(7));
|
||||
|
||||
Table::new(rows, widths)
|
||||
.header(Row::new(header).style(header_style()))
|
||||
.block(block)
|
||||
.column_spacing(1)
|
||||
.row_highlight_style(selected_style())
|
||||
}
|
||||
|
||||
fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) {
|
||||
let text: Vec<Line> = lines.iter().map(|l| Line::from(*l).style(dim())).collect();
|
||||
f.render_widget(
|
||||
Paragraph::new(text).alignment(Alignment::Center).block(block),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) {
|
||||
let show_album = area.width >= 76 && view == View::Results;
|
||||
let marker = match app.play_state {
|
||||
PlayState::Paused => "⏸",
|
||||
PlayState::Loading => "…",
|
||||
_ => "▶",
|
||||
};
|
||||
let now_id = app
|
||||
.now_track()
|
||||
.filter(|_| app.play_state != PlayState::Stopped)
|
||||
.map(|t| t.id.clone());
|
||||
let playing = now_id.as_deref().map(|id| (id, marker));
|
||||
|
||||
let (tracks, title) = match view {
|
||||
View::Queue => (&app.queue, " Queue ".to_string()),
|
||||
_ => (&app.results, format!(" {} ", app.results_title)),
|
||||
};
|
||||
let block = Block::bordered()
|
||||
.title(format!("{title}({}) ", tracks.len()))
|
||||
.border_style(Style::new().fg(ACCENT));
|
||||
|
||||
if tracks.is_empty() {
|
||||
let hint: &[&str] = if view == View::Queue {
|
||||
&["", "The queue is empty.", "Press Enter on a search result, or `a` to add it."]
|
||||
} else {
|
||||
&["", "No results yet.", "Press / and type a query, or use :search \"daft punk\""]
|
||||
};
|
||||
return empty_hint(f, block, area, hint);
|
||||
}
|
||||
let rows = track_rows(tracks, playing, show_album);
|
||||
let table = track_table(rows, show_album, block);
|
||||
let idx = view.idx();
|
||||
f.render_stateful_widget(table, area, &mut app.states[idx]);
|
||||
}
|
||||
|
||||
fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) {
|
||||
let marker = match app.play_state {
|
||||
PlayState::Paused => "⏸",
|
||||
PlayState::Loading => "…",
|
||||
_ => "▶",
|
||||
};
|
||||
let now_id = app
|
||||
.now_track()
|
||||
.filter(|_| app.play_state != PlayState::Stopped)
|
||||
.map(|t| t.id.clone());
|
||||
let playing = now_id.as_deref().map(|id| (id, marker));
|
||||
|
||||
let Some(tracks) = app.store.playlists.get(name) else { return };
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {name} ({}) ", tracks.len()))
|
||||
.border_style(Style::new().fg(ACCENT));
|
||||
if tracks.is_empty() {
|
||||
return empty_hint(
|
||||
f,
|
||||
block,
|
||||
area,
|
||||
&["", "This playlist is empty.", "Add songs with :addto \"name\" from Results or Queue."],
|
||||
);
|
||||
}
|
||||
let show_album = area.width >= 76;
|
||||
let rows = track_rows(tracks, playing, show_album);
|
||||
let table = track_table(rows, show_album, block);
|
||||
f.render_stateful_widget(table, area, &mut app.pl_state);
|
||||
}
|
||||
|
||||
fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
if app.in_playlist() {
|
||||
if let Some(name) = app.open.clone() {
|
||||
return draw_open_playlist(f, app, area, &name);
|
||||
}
|
||||
}
|
||||
let [left, right] =
|
||||
Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).areas(area);
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(format!(" Playlists ({}) ", app.store.playlists.len()))
|
||||
.border_style(Style::new().fg(ACCENT));
|
||||
|
||||
if app.store.playlists.is_empty() {
|
||||
empty_hint(
|
||||
f,
|
||||
block,
|
||||
left,
|
||||
&["", "No playlists yet.", "Queue some songs, then", ":save \"name\""],
|
||||
);
|
||||
let b = Block::bordered().title(" Preview ").border_style(dim());
|
||||
f.render_widget(b, right);
|
||||
return;
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = app
|
||||
.store
|
||||
.playlists
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, tracks))| {
|
||||
Row::new(vec![
|
||||
Cell::from(format!("{:>2}", i + 1)).style(dim()),
|
||||
Cell::from(name.as_str()),
|
||||
Cell::from(format!("{}", tracks.len())).style(dim()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[Constraint::Length(3), Constraint::Fill(1), Constraint::Length(5)],
|
||||
)
|
||||
.header(Row::new(vec!["", "Name", "Songs"]).style(header_style()))
|
||||
.block(block)
|
||||
.row_highlight_style(selected_style());
|
||||
f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]);
|
||||
|
||||
let sel = app.selected();
|
||||
let preview_block = Block::bordered().title(" Preview ").border_style(dim());
|
||||
match app.playlist_name(sel).and_then(|n| app.store.playlists.get(&n)) {
|
||||
Some(tracks) if !tracks.is_empty() => {
|
||||
let show_album = right.width >= 70;
|
||||
let rows = track_rows(tracks, None, show_album);
|
||||
f.render_widget(track_table(rows, show_album, preview_block), right);
|
||||
}
|
||||
_ => f.render_widget(preview_block, right),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
|
||||
let block = Block::bordered()
|
||||
.title(" Now Playing ")
|
||||
.border_style(Style::new().fg(ACCENT));
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
||||
|
||||
let mut flags = vec![format!("vol {}%", app.volume)];
|
||||
if app.shuffle {
|
||||
flags.push("shuffle".into());
|
||||
}
|
||||
if app.repeat != crate::model::RepeatMode::Off {
|
||||
flags.push(format!("repeat:{}", app.repeat.label()));
|
||||
}
|
||||
let flags = flags.join(" · ");
|
||||
let flags_w = flags.chars().count() as u16 + 1;
|
||||
let [title_area, flags_area] =
|
||||
Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w)]).areas(l1);
|
||||
f.render_widget(Paragraph::new(flags).style(dim()).alignment(Alignment::Right), flags_area);
|
||||
|
||||
let icon = match app.play_state {
|
||||
PlayState::Playing => "▶".to_string(),
|
||||
PlayState::Paused => "⏸".to_string(),
|
||||
PlayState::Loading => SPINNER[app.tick % SPINNER.len()].to_string(),
|
||||
PlayState::Stopped => "■".to_string(),
|
||||
};
|
||||
let line = match (app.now_track(), app.play_state) {
|
||||
(Some(t), s) if s != PlayState::Stopped => Line::from(vec![
|
||||
Span::styled(format!("{icon} "), Style::new().fg(Color::Green)),
|
||||
Span::styled(t.title.clone(), Style::new().bold()),
|
||||
Span::styled(format!(" {}", t.artist), Style::new().fg(Color::Gray)),
|
||||
]),
|
||||
(Some(t), _) => Line::from(vec![
|
||||
Span::styled(format!("{icon} "), dim()),
|
||||
Span::styled(format!("Last played: {} — {}", t.title, t.artist), dim()),
|
||||
]),
|
||||
_ => Line::from(vec![
|
||||
Span::styled(format!("{icon} "), dim()),
|
||||
Span::styled("Nothing playing", dim()),
|
||||
]),
|
||||
};
|
||||
f.render_widget(Paragraph::new(line), title_area);
|
||||
|
||||
let left = format!(" {} ", fmt_time(app.position));
|
||||
let right = format!(" {} ", if app.duration > 0.0 { fmt_time(app.duration) } else { "--:--".into() });
|
||||
let bar_w = (l2.width as usize).saturating_sub(left.chars().count() + right.chars().count());
|
||||
let ratio = if app.duration > 0.0 {
|
||||
(app.position / app.duration).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let filled = (ratio * bar_w as f64).round() as usize;
|
||||
let line = Line::from(vec![
|
||||
Span::raw(left),
|
||||
Span::styled("━".repeat(filled), Style::new().fg(ACCENT)),
|
||||
Span::styled("─".repeat(bar_w.saturating_sub(filled)), dim()),
|
||||
Span::raw(right),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(line), l2);
|
||||
}
|
||||
|
||||
fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) {
|
||||
if app.mode == Mode::Command {
|
||||
let text = format!(":{}", app.cmd_buf);
|
||||
let x = area.x + text.chars().count() as u16;
|
||||
f.render_widget(Paragraph::new(text), area);
|
||||
f.set_cursor_position((x.min(area.right().saturating_sub(1)), area.y));
|
||||
return;
|
||||
}
|
||||
|
||||
let line = match app.visible_status() {
|
||||
Some(s) => {
|
||||
let style = match s.level {
|
||||
Level::Error => Style::new().fg(Color::Red).bold(),
|
||||
Level::Info => Style::new().fg(Color::Green),
|
||||
};
|
||||
Line::styled(format!(" {}", s.text), style)
|
||||
}
|
||||
None => Line::default(),
|
||||
};
|
||||
f.render_widget(Paragraph::new(line), area);
|
||||
|
||||
let mut pending = String::new();
|
||||
if let Some(c) = app.count {
|
||||
pending.push_str(&c.to_string());
|
||||
}
|
||||
if let Some(p) = app.pending {
|
||||
pending.push(p);
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("{pending} ")).alignment(Alignment::Right).style(Style::new().fg(Color::Yellow)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const HELP: &[(&str, &str)] = &[
|
||||
("THIS WINDOW", "#"),
|
||||
("j k PgUp PgDn", "scroll"),
|
||||
("q Esc ?", "close"),
|
||||
("NAVIGATION", "#"),
|
||||
("j k / ↓ ↑", "move (counts work: 5j)"),
|
||||
("gg G", "top / bottom (5G → row 5)"),
|
||||
("Ctrl-d Ctrl-u", "half page down / up"),
|
||||
("Tab S-Tab", "cycle Results / Queue / Playlists"),
|
||||
("gr gq gp", "jump to Results / Queue / Playlists"),
|
||||
("PLAYBACK", "#"),
|
||||
("Enter", "play selected (on a playlist: load it; inside one: play from there)"),
|
||||
("Space", "pause / resume"),
|
||||
("n N (or > <)", "next / previous track"),
|
||||
("h l / ← →", "seek -5s / +5s (counts: 6l)"),
|
||||
("+ -", "volume up / down"),
|
||||
("s r", "toggle shuffle / cycle repeat"),
|
||||
("ga or :artist", "songs by the artist of the selected song; a popup asks which one when there are several"),
|
||||
("QUEUE", "#"),
|
||||
("a A P", "add selected / add all / play all results"),
|
||||
("dd x", "remove from queue / delete playlist / delete track in a playlist"),
|
||||
("J K", "move queue or playlist item down / up"),
|
||||
("PLAYLISTS", "#"),
|
||||
("→ or l", "open the highlighted playlist"),
|
||||
("← h Esc Backspace", "back to the playlist list"),
|
||||
("COMMANDS ( : or / )", "#"),
|
||||
(":search \"q\"", "search YouTube Music (/ prefills it)"),
|
||||
(":play [N]", "play or resume; N plays row N"),
|
||||
(":pause :resume :stop", "playback state"),
|
||||
(":next :prev", "skip"),
|
||||
(":seek 90 | 1:30 | +10", "absolute or relative seek"),
|
||||
(":vol 50 | +5", "set or change the volume"),
|
||||
(":shuffle :repeat [m]", "modes: off, all or one"),
|
||||
(":add [N] :addall", "queue a result / all results"),
|
||||
(":playall :remove [N]", "play all results / remove from queue"),
|
||||
(":clear", "empty the queue"),
|
||||
(":save n :load n", "save the queue as a playlist / load one"),
|
||||
(":append n :delpl n", "append a playlist to the queue / delete it"),
|
||||
(":addto n", "add the selected track to playlist n"),
|
||||
(":import URL", "load a YouTube / YT Music playlist"),
|
||||
(":artist", "same as ga"),
|
||||
(":quit (or q, Ctrl-c)", ""),
|
||||
];
|
||||
|
||||
const HELP_KEY_W: usize = 24;
|
||||
|
||||
fn wrap_words(text: &str, width: usize) -> Vec<String> {
|
||||
let width = width.max(1);
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
let cur_len = cur.chars().count();
|
||||
if !cur.is_empty() && cur_len + 1 + word.chars().count() > width {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
cur.push(' ');
|
||||
}
|
||||
cur.push_str(word);
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn help_lines(inner_w: usize) -> Vec<Line<'static>> {
|
||||
let key_w = HELP_KEY_W.min(inner_w / 2).max(1);
|
||||
let desc_w = inner_w.saturating_sub(key_w);
|
||||
let key_style = Style::new().fg(ACCENT);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
for (k, d) in HELP {
|
||||
if *d == "#" {
|
||||
if !lines.is_empty() {
|
||||
lines.push(Line::raw(""));
|
||||
}
|
||||
lines.push(Line::styled(k.to_string(), header_style()));
|
||||
continue;
|
||||
}
|
||||
let mut desc = wrap_words(d, desc_w).into_iter();
|
||||
if k.chars().count() < key_w {
|
||||
let first = desc.next().unwrap_or_default();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{k:<key_w$}"), key_style),
|
||||
Span::raw(first),
|
||||
]));
|
||||
} else {
|
||||
for part in wrap_words(k, inner_w) {
|
||||
lines.push(Line::styled(part, key_style));
|
||||
}
|
||||
}
|
||||
for rest in desc {
|
||||
lines.push(Line::from(vec![Span::raw(" ".repeat(key_w)), Span::raw(rest)]));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) {
|
||||
let n = app.artist_opts.len() as u16;
|
||||
let widest = app.artist_opts.iter().map(|a| a.chars().count()).max().unwrap_or(0) as u16;
|
||||
let w = (widest + 10).max(34).min(area.width);
|
||||
let h = (n + 4).min(area.height);
|
||||
let popup = Rect {
|
||||
x: area.x + (area.width - w) / 2,
|
||||
y: area.y + (area.height - h) / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
let mut lines: Vec<Line> = app
|
||||
.artist_opts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let text = format!(" {} {name}", i + 1);
|
||||
if i == app.artist_sel {
|
||||
Line::styled(text, selected_style())
|
||||
} else {
|
||||
Line::raw(text)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
lines.push(Line::styled(" Enter/1-9 select · Esc cancel", dim()));
|
||||
f.render_widget(Clear, popup);
|
||||
f.render_widget(
|
||||
Paragraph::new(lines).block(
|
||||
Block::bordered()
|
||||
.title(" Go to which artist? ")
|
||||
.border_style(Style::new().fg(Color::Yellow)),
|
||||
),
|
||||
popup,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_help(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
let w = area.width.min(80);
|
||||
let lines = help_lines(w.saturating_sub(2) as usize);
|
||||
let h = (lines.len() as u16).saturating_add(2).min(area.height);
|
||||
let popup = Rect {
|
||||
x: area.x + (area.width - w) / 2,
|
||||
y: area.y + (area.height - h) / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
|
||||
let visible = h.saturating_sub(2) as usize;
|
||||
let max_scroll = lines.len().saturating_sub(visible);
|
||||
app.help_scroll = app.help_scroll.min(max_scroll);
|
||||
let scroll = app.help_scroll;
|
||||
|
||||
let mut block = Block::bordered()
|
||||
.title(" Help — q or Esc to close ")
|
||||
.border_style(Style::new().fg(Color::Yellow));
|
||||
if max_scroll > 0 {
|
||||
let end = (scroll + visible).min(lines.len());
|
||||
block = block.title_bottom(format!(" j/k scroll · {}-{} of {} ", scroll + 1, end, lines.len()));
|
||||
}
|
||||
|
||||
let shown: Vec<Line> = lines.into_iter().skip(scroll).take(visible).collect();
|
||||
f.render_widget(Clear, popup);
|
||||
f.render_widget(Paragraph::new(shown).block(block), popup);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::App;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use std::sync::mpsc;
|
||||
|
||||
fn text(term: &Terminal<TestBackend>) -> String {
|
||||
term.backend().buffer().content().iter().map(|c| c.symbol()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_playlist_renders() {
|
||||
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
||||
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let mut app = App::new(tx);
|
||||
let t = |id: &str, title: &str| Track {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
artist: "Someone".into(),
|
||||
album: None,
|
||||
duration: Some(90),
|
||||
};
|
||||
app.store.playlists.insert("empty".into(), vec![]);
|
||||
app.store.playlists.insert("mix".into(), vec![t("aaaaaaaaaaa", "First Song"), t("bbbbbbbbbbb", "Second Song")]);
|
||||
let key = |app: &mut App, c| app.on_key(KeyEvent::new(c, KeyModifiers::NONE));
|
||||
|
||||
let mut term = Terminal::new(TestBackend::new(100, 24)).unwrap();
|
||||
key(&mut app, KeyCode::Tab);
|
||||
key(&mut app, KeyCode::Tab);
|
||||
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||
assert!(text(&term).contains("Playlists"));
|
||||
|
||||
key(&mut app, KeyCode::Right);
|
||||
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||
assert!(text(&term).contains("This playlist is empty"));
|
||||
key(&mut app, KeyCode::Left);
|
||||
|
||||
key(&mut app, KeyCode::Down);
|
||||
key(&mut app, KeyCode::Right);
|
||||
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||
let s = text(&term);
|
||||
assert!(s.contains("First Song") && s.contains("Second Song") && s.contains("mix"));
|
||||
assert!(!s.contains("Preview"));
|
||||
|
||||
let mut tiny = Terminal::new(TestBackend::new(20, 6)).unwrap();
|
||||
tiny.draw(|f| draw(f, &mut app)).unwrap();
|
||||
|
||||
key(&mut app, KeyCode::Char('?'));
|
||||
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||
let s = text(&term);
|
||||
assert!(s.contains("THIS WINDOW") && !s.contains(":quit"));
|
||||
key(&mut app, KeyCode::Char('G'));
|
||||
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||
assert!(text(&term).contains(":quit"));
|
||||
tiny.draw(|f| draw(f, &mut app)).unwrap();
|
||||
let mut narrow = Terminal::new(TestBackend::new(12, 5)).unwrap();
|
||||
narrow.draw(|f| draw(f, &mut app)).unwrap();
|
||||
key(&mut app, KeyCode::Char('q'));
|
||||
|
||||
}
|
||||
}
|
||||
320
src/ytmusic.rs
Normal file
320
src/ytmusic.rs
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::model::{is_valid_id, parse_clock, Track};
|
||||
|
||||
const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
|
||||
const SONGS_FILTER: &str = "EgWKAQIIAWoMEA4QChADEAQQCRAF";
|
||||
const UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
|
||||
const FALLBACK_COUNT: usize = 25;
|
||||
|
||||
pub fn search(query: &str) -> Result<Vec<Track>, String> {
|
||||
match search_innertube(query) {
|
||||
Ok(tracks) if !tracks.is_empty() => Ok(tracks),
|
||||
Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")),
|
||||
Err(primary) => ytdlp_search(query).map_err(|fallback| {
|
||||
format!("YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import(url: &str) -> Result<Vec<Track>, String> {
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return Err("import expects a full http(s) URL".into());
|
||||
}
|
||||
ytdlp_entries(url, &[]).map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
fn client_version() -> String {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(1_750_000_000);
|
||||
let days = (secs / 86_400) as i64 - 1;
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("1.{y:04}{m:02}{d:02}.01.00")
|
||||
}
|
||||
|
||||
fn civil_from_days(z: i64) -> (i64, i64, i64) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
fn search_innertube(query: &str) -> Result<Vec<Track>> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(12))
|
||||
.user_agent(UA)
|
||||
.build();
|
||||
let body = json!({
|
||||
"context": { "client": {
|
||||
"clientName": "WEB_REMIX",
|
||||
"clientVersion": client_version(),
|
||||
"hl": "en",
|
||||
"gl": "US",
|
||||
}},
|
||||
"query": query,
|
||||
"params": SONGS_FILTER,
|
||||
});
|
||||
let resp = agent
|
||||
.post(ENDPOINT)
|
||||
.set("Origin", "https://music.youtube.com")
|
||||
.set("Referer", "https://music.youtube.com/")
|
||||
.set("Cookie", "SOCS=CAI")
|
||||
.send_json(body)
|
||||
.map_err(|e| anyhow!("request failed: {e}"))?;
|
||||
let v: Value = resp.into_json().context("invalid JSON from YouTube Music")?;
|
||||
Ok(parse_search_response(&v))
|
||||
}
|
||||
|
||||
fn collect_items<'a>(v: &'a Value, out: &mut Vec<&'a Value>) {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
if let Some(item) = map.get("musicResponsiveListItemRenderer") {
|
||||
out.push(item);
|
||||
} else {
|
||||
for child in map.values() {
|
||||
collect_items(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => arr.iter().for_each(|c| collect_items(c, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::String(s)) = map.get(key) {
|
||||
return Some(s);
|
||||
}
|
||||
map.values().find_map(|c| find_str(c, key))
|
||||
}
|
||||
Value::Array(arr) => arr.iter().find_map(|c| find_str(c, key)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn column_segments(item: &Value, col: usize) -> Vec<String> {
|
||||
let runs = item
|
||||
.get("flexColumns")
|
||||
.and_then(|c| c.get(col))
|
||||
.and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs"))
|
||||
.and_then(Value::as_array);
|
||||
let mut segments = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for run in runs.into_iter().flatten() {
|
||||
let Some(text) = run.get("text").and_then(Value::as_str) else { continue };
|
||||
if text.trim() == "•" {
|
||||
segments.push(std::mem::take(&mut cur));
|
||||
} else {
|
||||
cur.push_str(text);
|
||||
}
|
||||
}
|
||||
segments.push(cur);
|
||||
segments
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn looks_like_duration(s: &str) -> bool {
|
||||
s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':')
|
||||
}
|
||||
|
||||
fn parse_item(item: &Value) -> Option<Track> {
|
||||
let id = find_str(item, "videoId")?;
|
||||
if !is_valid_id(id) {
|
||||
return None;
|
||||
}
|
||||
let title = column_segments(item, 0).into_iter().next()?;
|
||||
|
||||
let mut meta = column_segments(item, 1);
|
||||
if matches!(meta.first().map(String::as_str), Some("Song" | "Video" | "Episode")) {
|
||||
meta.remove(0);
|
||||
}
|
||||
let duration = match meta.last() {
|
||||
Some(last) if looks_like_duration(last) => {
|
||||
let d = parse_clock(last).map(|s| s as u32);
|
||||
meta.pop();
|
||||
d
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let artist = meta.first().cloned().unwrap_or_else(|| "Unknown artist".into());
|
||||
let album = meta
|
||||
.get(1)
|
||||
.filter(|a| !a.contains(" views") && !a.contains(" plays"))
|
||||
.cloned();
|
||||
|
||||
Some(Track {
|
||||
id: id.to_string(),
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_search_response(v: &Value) -> Vec<Track> {
|
||||
let mut items = Vec::new();
|
||||
collect_items(v, &mut items);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(parse_item)
|
||||
.filter(|t| seen.insert(t.id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ytdlp_search(query: &str) -> Result<Vec<Track>> {
|
||||
ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}"), &[])
|
||||
}
|
||||
|
||||
fn ytdlp_entries(target: &str, extra: &[&str]) -> Result<Vec<Track>> {
|
||||
let out = Command::new("yt-dlp")
|
||||
.args([
|
||||
"--flat-playlist",
|
||||
"--dump-json",
|
||||
"--no-warnings",
|
||||
"--ignore-errors",
|
||||
"--socket-timeout",
|
||||
"15",
|
||||
])
|
||||
.args(extra)
|
||||
.arg(target)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;
|
||||
|
||||
let tracks = parse_ytdlp_lines(&String::from_utf8_lossy(&out.stdout));
|
||||
if tracks.is_empty() && !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let last = stderr
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("unknown error");
|
||||
bail!("yt-dlp failed: {}", last.trim());
|
||||
}
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
pub fn parse_ytdlp_lines(text: &str) -> Vec<Track> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
text.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter_map(|v| {
|
||||
let id = v.get("id")?.as_str()?;
|
||||
if !is_valid_id(id) {
|
||||
return None;
|
||||
}
|
||||
let title = v.get("title")?.as_str()?.trim().to_string();
|
||||
let artist = ["artist", "channel", "uploader"]
|
||||
.iter()
|
||||
.find_map(|k| v.get(*k).and_then(Value::as_str))
|
||||
.unwrap_or("Unknown artist")
|
||||
.trim_end_matches(" - Topic")
|
||||
.to_string();
|
||||
let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32);
|
||||
Some(Track {
|
||||
id: id.to_string(),
|
||||
title,
|
||||
artist,
|
||||
album: None,
|
||||
duration,
|
||||
})
|
||||
})
|
||||
.filter(|t| seen.insert(t.id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(text: &str) -> Value {
|
||||
json!({"text": {"runs": [{"text": text}]}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_song_item() {
|
||||
let resp = json!({"contents": {"shelf": [{"musicResponsiveListItemRenderer": {
|
||||
"playlistItemData": {"videoId": "dQw4w9WgXcQ"},
|
||||
"flexColumns": [
|
||||
{"musicResponsiveListItemFlexColumnRenderer": run("Never Gonna Give You Up")},
|
||||
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [
|
||||
{"text": "Rick Astley"}, {"text": " • "},
|
||||
{"text": "Whenever You Need Somebody"}, {"text": " • "},
|
||||
{"text": "3:33"}
|
||||
]}}}
|
||||
]
|
||||
}}]}});
|
||||
let tracks = parse_search_response(&resp);
|
||||
assert_eq!(tracks.len(), 1);
|
||||
let t = &tracks[0];
|
||||
assert_eq!(t.id, "dQw4w9WgXcQ");
|
||||
assert_eq!(t.title, "Never Gonna Give You Up");
|
||||
assert_eq!(t.artist, "Rick Astley");
|
||||
assert_eq!(t.album.as_deref(), Some("Whenever You Need Somebody"));
|
||||
assert_eq!(t.duration, Some(213));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multi_artist_and_type_prefix() {
|
||||
let resp = json!([{"musicResponsiveListItemRenderer": {
|
||||
"overlay": {"watchEndpoint": {"videoId": "abcdefghijk"}},
|
||||
"flexColumns": [
|
||||
{"musicResponsiveListItemFlexColumnRenderer": run("Song")},
|
||||
{"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [
|
||||
{"text": "Video"}, {"text": " • "},
|
||||
{"text": "A"}, {"text": " & "}, {"text": "B"}, {"text": " • "},
|
||||
{"text": "1.2M views"}, {"text": " • "}, {"text": "4:01"}
|
||||
]}}}
|
||||
]
|
||||
}}]);
|
||||
let t = &parse_search_response(&resp)[0];
|
||||
assert_eq!(t.artist, "A & B");
|
||||
assert_eq!(t.album, None);
|
||||
assert_eq!(t.duration, Some(241));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_items_without_video_id() {
|
||||
let resp = json!([{"musicResponsiveListItemRenderer": {
|
||||
"navigationEndpoint": {"browseId": "UC123"},
|
||||
"flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run("Artist")}]
|
||||
}}]);
|
||||
assert!(parse_search_response(&resp).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ytdlp_output() {
|
||||
let text = r#"{"id":"dQw4w9WgXcQ","title":"Song","channel":"Rick - Topic","duration":213.0}
|
||||
not json
|
||||
{"id":"UCxxxxxxxxxxxxxxxxxxxxxx","title":"A channel"}
|
||||
{"id":"dQw4w9WgXcQ","title":"dup"}"#;
|
||||
let t = parse_ytdlp_lines(text);
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!(t[0].artist, "Rick");
|
||||
assert_eq!(t[0].duration, Some(213));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_math() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue