diff --git a/src/app.rs b/src/app.rs index ad17e82..9be0ff9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use crate::store::Store; use crate::ytmusic; pub enum AppEvent { - Player(PlayerEvent), + Player(u64, PlayerEvent), Results { id: u64, title: String, @@ -105,6 +105,7 @@ pub struct App { on_radio: bool, seek_step: f64, volume_step: f64, + autoplay: bool, resume: Option<(String, f64)>, saved: String, save_failed: bool, @@ -116,6 +117,7 @@ pub struct App { search_id: u64, player: Option, + player_gen: u64, tx: Sender, rng: u64, } @@ -164,6 +166,7 @@ impl App { on_radio: false, seek_step: p.seek_step, volume_step: p.volume_step, + autoplay: p.autoplay, resume: None, saved: String::new(), save_failed: false, @@ -173,6 +176,7 @@ impl App { should_quit: false, search_id: 0, player: None, + player_gen: 0, tx, rng: seed, }; @@ -226,13 +230,17 @@ impl App { } fn set_status(&mut self, text: String, level: Level) { - self.status = Some(Status { text, level, at: Instant::now() }); + self.status = Some(Status { + text, + level, + at: Instant::now(), + }); } pub fn visible_status(&self) -> Option<&Status> { - self.status.as_ref().filter(|s| { - s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 } - }) + self.status + .as_ref() + .filter(|s| s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 }) } pub fn on_tick(&mut self) { @@ -259,11 +267,17 @@ impl App { pub fn in_playlist(&self) -> bool { self.view == View::Playlists - && self.open.as_ref().is_some_and(|n| self.store.playlists.contains_key(n)) + && self + .open + .as_ref() + .is_some_and(|n| self.store.playlists.contains_key(n)) } pub fn open_len(&self) -> usize { - self.open.as_ref().and_then(|n| self.store.playlists.get(n)).map_or(0, |t| t.len()) + self.open + .as_ref() + .and_then(|n| self.store.playlists.get(n)) + .map_or(0, |t| t.len()) } fn cur_len(&self) -> usize { @@ -411,8 +425,12 @@ impl App { KeyCode::Char(' ') => self.toggle_pause(), KeyCode::Char('n') | KeyCode::Char('>') => self.next(), KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), - KeyCode::Char('h') | KeyCode::Left => self.seek(Amount::Delta(-self.seek_step * times as f64)), - KeyCode::Char('l') | KeyCode::Right => self.seek(Amount::Delta(self.seek_step * times as f64)), + KeyCode::Char('h') | KeyCode::Left => { + self.seek(Amount::Delta(-self.seek_step * times as f64)) + } + KeyCode::Char('l') | KeyCode::Right => { + self.seek(Amount::Delta(self.seek_step * times as f64)) + } KeyCode::Char('+') | KeyCode::Char('=') => { self.set_volume(Amount::Delta(self.volume_step * times as f64)) } @@ -562,7 +580,7 @@ impl App { Command::Search(q) => self.start_search(q), Command::Play(None) => self.play_default(), Command::Play(Some(n)) => { - let len = self.len_of(self.view); + let len = self.cur_len(); if n > len { self.error(format!("no row {n} (only {len} here)")); } else { @@ -584,10 +602,12 @@ impl App { }, Command::AddAll => self.add_all_results(), Command::PlayAll => self.play_all_results(), - Command::Remove(n) => { - let i = n.map_or(self.selected(), |n| n - 1); + Command::Remove(Some(n)) => self.remove_from_queue(n - 1), + Command::Remove(None) if self.view == View::Queue => { + let i = self.selected(); self.remove_from_queue(i); } + Command::Remove(None) => self.error("Open the queue or give a row number: :remove N"), Command::Clear => self.clear_queue(), Command::ShowResults => self.set_view(View::Results), Command::ShowQueue => self.set_view(View::Queue), @@ -660,7 +680,9 @@ impl App { } match key.code { KeyCode::Esc | KeyCode::Char('q') => self.close_artist_pick(), - KeyCode::Char('j') | KeyCode::Down => self.artist_sel = (self.artist_sel + 1).min(len - 1), + KeyCode::Char('j') | KeyCode::Down => { + self.artist_sel = (self.artist_sel + 1).min(len - 1) + } KeyCode::Char('k') | KeyCode::Up => self.artist_sel = self.artist_sel.saturating_sub(1), KeyCode::Char(c @ '1'..='9') => { let i = c as usize - '1' as usize; @@ -698,22 +720,28 @@ impl App { .into_iter() .filter(|t| { t.artist.to_lowercase() == wanted - || split_artists(&t.artist).iter().any(|a| a.to_lowercase() == wanted) + || split_artists(&t.artist) + .iter() + .any(|a| a.to_lowercase() == wanted) }) .collect()) }); } fn start_search(&mut self, query: String) { - self.spawn_loader(format!("Search: {query}"), format!("Searching \u{201c}{query}\u{201d}"), move || { - ytmusic::search(&query) - }); + self.spawn_loader( + format!("Search: {query}"), + format!("Searching \u{201c}{query}\u{201d}"), + move || ytmusic::search(&query), + ); } fn start_import(&mut self, url: String) { - self.spawn_loader("Imported playlist".into(), "Importing playlist".into(), move || { - ytmusic::import(&url) - }); + self.spawn_loader( + "Imported playlist".into(), + "Importing playlist".into(), + move || ytmusic::import(&url), + ); } fn spawn_loader(&mut self, title: String, busy: String, work: F) @@ -750,7 +778,11 @@ impl App { Err(e) => self.error(e), } } - AppEvent::Player(pe) => self.on_player(pe), + AppEvent::Player(generation, pe) => { + if generation == self.player_gen { + self.on_player(pe); + } + } } } @@ -761,9 +793,11 @@ impl App { } } if self.player.is_none() { + self.player_gen += 1; + let generation = self.player_gen; let tx = self.tx.clone(); let p = Player::spawn(self.volume, move |ev| { - let _ = tx.send(AppEvent::Player(ev)); + let _ = tx.send(AppEvent::Player(generation, ev)); })?; self.player = Some(p); } @@ -819,7 +853,9 @@ impl App { "Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U" )); } else { - self.error(format!("Can't play \u{201c}{name}\u{201d} ({msg}) — skipping")); + self.error(format!( + "Can't play \u{201c}{name}\u{201d} ({msg}) — skipping" + )); self.advance(true); } } @@ -838,7 +874,9 @@ impl App { } fn start_track(&mut self, i: usize, keep_resume: bool) { - let Some(track) = self.queue.get(i).cloned() else { return }; + let Some(track) = self.queue.get(i).cloned() else { + return; + }; if !keep_resume { self.resume = None; } @@ -929,7 +967,9 @@ impl App { } fn play_radio(&mut self, i: usize) { - let Some(track) = self.radio.get(i).cloned() else { return }; + let Some(track) = self.radio.get(i).cloned() else { + return; + }; self.on_radio = true; self.result_cursor = Some(i); self.resume = None; @@ -954,7 +994,11 @@ impl App { } match self.with_player(|p| p.set_pause(pause)) { Ok(()) => { - self.play_state = if pause { PlayState::Paused } else { PlayState::Playing }; + self.play_state = if pause { + PlayState::Paused + } else { + PlayState::Playing + }; } Err(e) => self.error(format!("{e:#}")), } @@ -1062,7 +1106,11 @@ impl App { } fn advance_radio(&mut self, auto: bool) { - let next = self.result_cursor.map(|c| c + 1).filter(|&i| i < self.radio.len()); + let next = self + .result_cursor + .filter(|_| self.on_radio || self.autoplay) + .map(|c| c + 1) + .filter(|&i| i < self.radio.len()); match next { Some(i) => self.play_radio(i), None if auto => { @@ -1114,7 +1162,10 @@ impl App { if let Some(c) = self.current { self.played.insert(c); } - self.info(format!("Shuffle: {}", if self.shuffle { "on" } else { "off" })); + self.info(format!( + "Shuffle: {}", + if self.shuffle { "on" } else { "off" } + )); } fn cycle_repeat(&mut self) { @@ -1168,7 +1219,11 @@ impl App { if self.results.is_empty() { return self.error("No results to play"); } - let start = if self.view == View::Results { self.selected() } else { 0 }; + let start = if self.view == View::Results { + self.selected() + } else { + 0 + }; self.queue = self.results.clone(); self.played.clear(); self.current = None; @@ -1211,7 +1266,11 @@ impl App { } let sel = self.states[View::Queue.idx()].selected().unwrap_or(0); let len = self.queue.len(); - self.states[View::Queue.idx()].select(if len == 0 { None } else { Some(sel.min(len - 1)) }); + self.states[View::Queue.idx()].select(if len == 0 { + None + } else { + Some(sel.min(len - 1)) + }); self.info(format!("Removed: {}", removed.label())); } @@ -1240,8 +1299,14 @@ impl App { match self.view { View::Queue => self.remove_from_queue(i), View::Playlists if self.in_playlist() => self.remove_playlist_track(i), - View::Playlists => if let Some(n) = self.playlist_name(i) { self.delete_playlist(&n) }, - View::Results => self.info("Results can't be deleted — they are replaced by the next search"), + View::Playlists => { + if let Some(n) = self.playlist_name(i) { + self.delete_playlist(&n) + } + } + View::Results => { + self.info("Results can't be deleted — they are replaced by the next search") + } } } @@ -1260,9 +1325,13 @@ impl App { return self.error("Queue is empty — nothing to save"); } let n = self.queue.len(); - self.store.playlists.insert(name.to_string(), self.queue.clone()); + self.store + .playlists + .insert(name.to_string(), self.queue.clone()); if self.persist() { - self.info(format!("Saved {n} tracks to playlist \u{201c}{name}\u{201d}")); + self.info(format!( + "Saved {n} tracks to playlist \u{201c}{name}\u{201d}" + )); } } @@ -1308,10 +1377,14 @@ impl App { self.result_cursor = None; self.states[View::Queue.idx()].select(Some(0)); self.play_index(0); - self.info(format!("Playing playlist \u{201c}{name}\u{201d} ({n} tracks)")); + self.info(format!( + "Playing playlist \u{201c}{name}\u{201d} ({n} tracks)" + )); } else { self.queue.extend(tracks); - self.info(format!("Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue")); + self.info(format!( + "Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue" + )); } } @@ -1345,9 +1418,13 @@ impl App { } fn remove_playlist_track(&mut self, i: usize) { - let Some(name) = self.open.clone() else { return }; + let Some(name) = self.open.clone() else { + return; + }; let removed = { - let Some(list) = self.store.playlists.get_mut(&name) else { return }; + let Some(list) = self.store.playlists.get_mut(&name) else { + return; + }; if i >= list.len() { None } else { @@ -1358,9 +1435,13 @@ impl App { let Some((track, len)) = removed else { return self.error("No track selected"); }; - self.pl_state.select(if len == 0 { None } else { Some(i.min(len - 1)) }); + self.pl_state + .select(if len == 0 { None } else { Some(i.min(len - 1)) }); if self.persist() { - self.info(format!("Removed from \u{201c}{name}\u{201d}: {}", track.label())); + self.info(format!( + "Removed from \u{201c}{name}\u{201d}: {}", + track.label() + )); } } @@ -1372,7 +1453,9 @@ impl App { } fn move_playlist_track(&mut self, dir: isize) { - let Some(name) = self.open.clone() else { return }; + let Some(name) = self.open.clone() else { + return; + }; let i = self.selected(); let j = i as isize + dir; let moved = match self.store.playlists.get_mut(&name) { @@ -1447,9 +1530,15 @@ mod tests { let mut app = App::new(tx); app.store.playlists.insert( "mix".into(), - vec![track("aaaaaaaaaaa", "A"), track("bbbbbbbbbbb", "B"), track("ccccccccccc", "C")], + vec![ + track("aaaaaaaaaaa", "A"), + track("bbbbbbbbbbb", "B"), + track("ccccccccccc", "C"), + ], ); - app.store.playlists.insert("other".into(), vec![track("ddddddddddd", "D")]); + app.store + .playlists + .insert("other".into(), vec![track("ddddddddddd", "D")]); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); @@ -1468,12 +1557,18 @@ mod tests { assert_eq!(app.selected(), 2); press(&mut app, KeyCode::Char('K')); - let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect(); + let ids: Vec<&str> = app.store.playlists["mix"] + .iter() + .map(|t| t.id.as_str()) + .collect(); assert_eq!(ids, ["aaaaaaaaaaa", "ccccccccccc", "bbbbbbbbbbb"]); assert_eq!(app.selected(), 1); press(&mut app, KeyCode::Char('x')); - let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect(); + let ids: Vec<&str> = app.store.playlists["mix"] + .iter() + .map(|t| t.id.as_str()) + .collect(); assert_eq!(ids, ["aaaaaaaaaaa", "bbbbbbbbbbb"]); assert_eq!(app.selected(), 1); @@ -1504,6 +1599,9 @@ mod tests { check_artist_popup(&mut app); + app.run_command("remove"); + assert_eq!(app.queue.len(), 1); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/command.rs b/src/command.rs index ac98901..c2881be 100644 --- a/src/command.rs +++ b/src/command.rs @@ -40,9 +40,35 @@ pub enum Command { } 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", + "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 { @@ -61,7 +87,9 @@ fn index_arg(arg: &str, cmd: &str) -> Result, String> { } match arg.parse::() { Ok(n) if n >= 1 => Ok(Some(n)), - _ => Err(format!(":{cmd} expects a row number (1, 2, 3 …), got '{arg}'")), + _ => Err(format!( + ":{cmd} expects a row number (1, 2, 3 …), got '{arg}'" + )), } } @@ -159,9 +187,18 @@ mod tests { #[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_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()); } @@ -178,10 +215,22 @@ mod tests { #[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_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()); } @@ -193,7 +242,10 @@ mod tests { #[test] fn playlists() { - assert_eq!(parse(r#"save "my mix""#), Ok(Some(Command::Save("my mix".into())))); + assert_eq!( + parse(r#"save "my mix""#), + Ok(Some(Command::Save("my mix".into()))) + ); assert!(parse("save").is_err()); } } diff --git a/src/config.rs b/src/config.rs index c47db6c..cd1577c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,12 +1,12 @@ use std::fs; use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::Result; use serde::Deserialize; use crate::model::{RepeatMode, Track}; use crate::session::Session; -use crate::store::Store; +use crate::store::{write_atomic, Store}; #[derive(Debug, Clone, Copy, Deserialize)] #[serde(default)] @@ -56,14 +56,14 @@ pub struct Config { impl Config { fn path() -> Result { - Ok(Store::path()?.with_file_name("config.toml")) + Ok(Store::dir()?.join("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 +# Drift settings. Drift only reads this file and never rewrites it, so your +# comments are safe. Restart drift after editing. [playback] volume = {volume} @@ -85,14 +85,7 @@ volume_step = {vstep:?} } 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(()) + write_atomic(&Self::path()?, text) } pub fn load() -> (Config, Option) { @@ -106,7 +99,10 @@ volume_step = {vstep:?} return (Config::default(), None); } Err(e) => { - return (Config::default(), Some(format!("cannot read {}: {e}", path.display()))) + return ( + Config::default(), + Some(format!("cannot read {}: {e}", path.display())), + ) } }; match toml::from_str::(&text) { @@ -144,12 +140,20 @@ pub fn migrate_legacy() { } let Ok(path) = Config::path() else { return }; - let Ok(text) = fs::read_to_string(&path) else { return }; - let Ok(table) = text.parse::() else { return }; - if table.contains_key("playback") || !(table.contains_key("queue") || table.contains_key("position")) { + let Ok(text) = fs::read_to_string(&path) else { + return; + }; + let Ok(table) = text.parse::() else { + return; + }; + if table.contains_key("playback") + || !(table.contains_key("queue") || table.contains_key("position")) + { return; } - let Ok(old) = toml::from_str::(&text) else { return }; + let Ok(old) = toml::from_str::(&text) else { + return; + }; let d = Playback::default(); let mut p = Playback { @@ -160,7 +164,11 @@ pub fn migrate_legacy() { ..d }; p.sanitize(); - let session = Session { queue: old.queue, current: old.current, position: old.position }; + 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); @@ -190,14 +198,22 @@ mod tests { 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.volume, c.playback.repeat), + (10, RepeatMode::One) + ); assert_eq!(c.playback.seek_step, 10.0); assert!(toml::from_str::("[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() }; + 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)); } diff --git a/src/main.rs b/src/main.rs index 0896abc..d685433 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,8 +49,14 @@ fn tool_version(name: &str) -> Option { fn missing_dependencies(verbose: bool) -> Vec { 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"), + ( + "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) => { @@ -122,7 +128,7 @@ fn run(initial_query: Option) -> Result<()> { app.run_command(&format!("search {q}")); } - let mut terminal = ratatui::init(); + let mut terminal = ratatui::try_init()?; let result = event_loop(&mut terminal, &mut app, &rx); ratatui::restore(); app.shutdown(); diff --git a/src/model.rs b/src/model.rs index 799803a..685c42c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -24,7 +24,9 @@ impl Track { pub fn split_artists(credit: &str) -> Vec { const MARK: char = '\u{1}'; let mut s = credit.to_string(); - for sep in [" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × "] { + for sep in [ + " & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ", + ] { s = s.replace(sep, &MARK.to_string()); } let mut out: Vec = Vec::new(); @@ -52,7 +54,7 @@ pub fn parse_clock(s: &str) -> Option { let mut total = 0.0; for part in s.split(':') { let v: f64 = part.trim().parse().ok()?; - if v < 0.0 { + if !v.is_finite() || v < 0.0 { return None; } total = total * 60.0 + v; @@ -61,7 +63,11 @@ pub fn parse_clock(s: &str) -> Option { } pub fn fmt_time(secs: f64) -> String { - let s = if secs.is_finite() { secs.max(0.0) as u64 } else { 0 }; + 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}") @@ -104,8 +110,10 @@ mod tests { 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("90"), Some(90.0)); assert_eq!(parse_clock("abc"), None); + assert_eq!(parse_clock("NaN"), None); + assert_eq!(parse_clock("inf"), None); assert_eq!(fmt_time(225.0), "3:45"); assert_eq!(fmt_time(3723.0), "1:02:03"); } diff --git a/src/player.rs b/src/player.rs index ac631c9..37806af 100644 --- a/src/player.rs +++ b/src/player.rs @@ -26,12 +26,9 @@ pub struct Player { } impl Player { - pub fn spawn( - volume: u8, - on_event: impl Fn(PlayerEvent) + Send + 'static, - ) -> Result { - let sock_path = - std::env::temp_dir().join(format!("drift-{}.sock", std::process::id())); + pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result { + let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir); + let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id())); let _ = std::fs::remove_file(&sock_path); let mut cmd = Command::new("mpv"); @@ -51,13 +48,14 @@ impl Player { .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")) { + 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()); + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); #[cfg(target_os = "linux")] unsafe { @@ -101,14 +99,21 @@ impl Player { sock_path, }; - for (id, prop) in [(1, "time-pos"), (2, "duration"), (3, "pause"), (4, "volume")] { + 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::(&line) else { continue }; + let Ok(v) = serde_json::from_str::(&line) else { + continue; + }; if let Some(ev) = parse_event(&v) { on_event(ev); } @@ -211,7 +216,9 @@ mod tests { fn mpv_roundtrip() { use std::sync::mpsc; let wav = std::env::temp_dir().join("drift-test.wav"); - let samples: Vec = (0..16_000u32).map(|i| if (i / 20) % 2 == 0 { 140 } else { 116 }).collect(); + let samples: Vec = (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()); diff --git a/src/session.rs b/src/session.rs index 4eb6a74..7af170a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,10 +1,11 @@ use std::fs; use std::path::PathBuf; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use crate::model::{is_valid_id, Track}; +use crate::store::write_atomic; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] @@ -27,8 +28,12 @@ impl Session { } 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() }; + 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::(&text) { Ok(mut s) => { s.sanitize(); @@ -58,14 +63,7 @@ impl Session { } 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(()) + write_atomic(&Self::path()?, text) } } @@ -74,25 +72,37 @@ mod tests { use super::*; fn track(id: &str) -> Track { - Track { id: id.into(), title: "T".into(), artist: "A".into(), album: None, duration: Some(200) } + Track { + id: id.into(), + title: "T".into(), + artist: "A".into(), + album: None, + duration: Some(200), + } } #[test] - fn roundtrip_and_sanitize() { - let s = Session { + fn sanitize_drops_bad_tracks_and_clamps() { + let mut 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)); + s.sanitize(); + assert_eq!((s.queue.len(), s.current, s.position), (1, Some(0), 0.0)); + } - let ok = Session { queue: vec![track("aaaaaaaaaaa")], current: Some(0), position: 42.5 }; + #[test] + fn roundtrip() { + 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)); + assert_eq!( + (again.current, again.position, again.queue), + (Some(0), 42.5, ok.queue) + ); } } diff --git a/src/store.rs b/src/store.rs index 2184222..71a776f 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; @@ -12,15 +12,29 @@ pub struct Store { pub playlists: BTreeMap>, } +pub fn write_atomic(path: &Path, text: &str) -> Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let tmp = path.with_extension("tmp"); + fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?; + Ok(()) +} + impl Store { - pub fn path() -> Result { + pub fn dir() -> Result { 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")) + Ok(new_dir) + } + + fn path() -> Result { + Ok(Self::dir()?.join("playlists.json")) } pub fn load() -> (Store, Option) { @@ -55,15 +69,7 @@ impl Store { } 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(()) + write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?) } pub fn resolve(&self, name: &str) -> Option { diff --git a/src/ui.rs b/src/ui.rs index 109c3fb..0748f06 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -11,7 +11,10 @@ const ACCENT: Color = Color::Cyan; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; fn selected_style() -> Style { - Style::new().fg(Color::Black).bg(ACCENT).add_modifier(Modifier::BOLD) + Style::new() + .fg(Color::Black) + .bg(ACCENT) + .add_modifier(Modifier::BOLD) } fn header_style() -> Style { @@ -56,7 +59,10 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { ]) .areas(area); - f.render_widget(Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), name); + f.render_widget( + Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), + name, + ); let titles = [ format!(" Results {} ", app.results.len()), @@ -83,6 +89,20 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { } } +fn playing_marker(app: &App) -> Option<(String, &'static str)> { + let id = app + .now_track() + .filter(|_| app.play_state != PlayState::Stopped)? + .id + .clone(); + let marker = match app.play_state { + PlayState::Paused => "⏸", + PlayState::Loading => "…", + _ => "▶", + }; + Some((id, marker)) +} + fn track_rows<'a>( tracks: &'a [Track], playing: Option<(&str, &'a str)>, @@ -93,7 +113,11 @@ fn track_rows<'a>( .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 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()), @@ -104,8 +128,7 @@ fn track_rows<'a>( 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()), + Cell::from(t.duration.map_or(String::new(), |d| fmt_time(d as f64))).style(dim()), ); let row = Row::new(cells); if is_playing { @@ -142,23 +165,17 @@ fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>) -> Ta fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) { let text: Vec = lines.iter().map(|l| Line::from(*l).style(dim())).collect(); f.render_widget( - Paragraph::new(text).alignment(Alignment::Center).block(block), + 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 now = playing_marker(app); + let playing = now.as_ref().map(|(id, marker)| (id.as_str(), *marker)); let (tracks, title) = match view { View::Queue => (&app.queue, " Queue ".to_string()), @@ -170,9 +187,17 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { if tracks.is_empty() { let hint: &[&str] = if view == View::Queue { - &["", "The queue is empty.", "Press `a` on a search result to queue it."] + &[ + "", + "The queue is empty.", + "Press `a` on a search result to queue it.", + ] } else { - &["", "No results yet.", "Press / and type a query, or use :search \"daft punk\""] + &[ + "", + "No results yet.", + "Press / and type a query, or use :search \"daft punk\"", + ] }; return empty_hint(f, block, area, hint); } @@ -183,18 +208,12 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { } 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 now = playing_marker(app); + let playing = now.as_ref().map(|(id, marker)| (id.as_str(), *marker)); - let Some(tracks) = app.store.playlists.get(name) else { return }; + 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)); @@ -203,7 +222,11 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { f, block, area, - &["", "This playlist is empty.", "Add songs with :addto \"name\" from Results or Queue."], + &[ + "", + "This playlist is empty.", + "Add songs with :addto \"name\" from Results or Queue.", + ], ); } let show_album = area.width >= 76; @@ -230,7 +253,12 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { f, block, left, - &["", "No playlists yet.", "Queue some songs, then", ":save \"name\""], + &[ + "", + "No playlists yet.", + "Queue some songs, then", + ":save \"name\"", + ], ); let b = Block::bordered().title(" Preview ").border_style(dim()); f.render_widget(b, right); @@ -252,7 +280,11 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { .collect(); let table = Table::new( rows, - [Constraint::Length(3), Constraint::Fill(1), Constraint::Length(5)], + [ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(5), + ], ) .header(Row::new(vec!["", "Name", "Songs"]).style(header_style())) .block(block) @@ -261,7 +293,10 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { 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)) { + 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); @@ -290,7 +325,12 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { 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); + f.render_widget( + Paragraph::new(flags) + .style(dim()) + .alignment(Alignment::Right), + flags_area, + ); let icon = match app.play_state { PlayState::Playing => "▶".to_string(), @@ -316,7 +356,14 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { 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 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) @@ -336,9 +383,12 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { 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; + let col = text + .chars() + .count() + .min(area.width.saturating_sub(1) as usize) as u16; f.render_widget(Paragraph::new(text), area); - f.set_cursor_position((x.min(area.right().saturating_sub(1)), area.y)); + f.set_cursor_position((area.x + col, area.y)); return; } @@ -363,7 +413,9 @@ fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) { } if !pending.is_empty() { f.render_widget( - Paragraph::new(format!("{pending} ")).alignment(Alignment::Right).style(Style::new().fg(Color::Yellow)), + Paragraph::new(format!("{pending} ")) + .alignment(Alignment::Right) + .style(Style::new().fg(Color::Yellow)), area, ); } @@ -380,16 +432,25 @@ const HELP: &[(&str, &str)] = &[ ("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)"), + ( + "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"), + ( + "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"), + ( + "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"), @@ -403,10 +464,19 @@ const HELP: &[(&str, &str)] = &[ (":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"), + ( + ":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"), + ( + ":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"), @@ -462,7 +532,10 @@ fn help_lines(inner_w: usize) -> Vec> { } } for rest in desc { - lines.push(Line::from(vec![Span::raw(" ".repeat(key_w)), Span::raw(rest)])); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(key_w)), + Span::raw(rest), + ])); } } lines @@ -470,9 +543,14 @@ fn help_lines(inner_w: usize) -> Vec> { 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 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 h = (n + 3).min(area.height); let popup = Rect { x: area.x + (area.width - w) / 2, y: area.y + (area.height - h) / 2, @@ -525,7 +603,12 @@ fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { .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())); + block = block.title_bottom(format!( + " j/k scroll · {}-{} of {} ", + scroll + 1, + end, + lines.len() + )); } let shown: Vec = lines.into_iter().skip(scroll).take(visible).collect(); @@ -543,14 +626,20 @@ mod tests { use std::sync::mpsc; fn text(term: &Terminal) -> String { - term.backend().buffer().content().iter().map(|c| c.symbol()).collect() + 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())); + let dir = std::env::temp_dir().join(format!("drift-ui-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::env::set_var("XDG_CONFIG_HOME", &dir); + std::env::set_var("XDG_STATE_HOME", &dir); let (tx, _rx) = mpsc::channel(); let mut app = App::new(tx); @@ -562,7 +651,13 @@ mod tests { 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")]); + 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(); @@ -598,5 +693,6 @@ mod tests { narrow.draw(|f| draw(f, &mut app)).unwrap(); key(&mut app, KeyCode::Char('q')); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/ytmusic.rs b/src/ytmusic.rs index 6215288..c6ee46f 100644 --- a/src/ytmusic.rs +++ b/src/ytmusic.rs @@ -16,7 +16,9 @@ pub fn search(query: &str) -> Result, String> { 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:#})") + format!( + "YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})" + ) }), } } @@ -25,7 +27,7 @@ pub fn import(url: &str) -> Result, 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:#}")) + ytdlp_entries(url).map_err(|e| format!("{e:#}")) } fn client_version() -> String { @@ -73,7 +75,9 @@ fn search_innertube(query: &str) -> Result> { .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")?; + let v: Value = resp + .into_json() + .context("invalid JSON from YouTube Music")?; Ok(parse_search_response(&v)) } @@ -115,7 +119,9 @@ fn column_segments(item: &Value, col: usize) -> Vec { 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 }; + let Some(text) = run.get("text").and_then(Value::as_str) else { + continue; + }; if text.trim() == "•" { segments.push(std::mem::take(&mut cur)); } else { @@ -142,7 +148,10 @@ fn parse_item(item: &Value) -> Option { 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")) { + if matches!( + meta.first().map(String::as_str), + Some("Song" | "Video" | "Episode") + ) { meta.remove(0); } let duration = match meta.last() { @@ -153,7 +162,10 @@ fn parse_item(item: &Value) -> Option { } _ => None, }; - let artist = meta.first().cloned().unwrap_or_else(|| "Unknown artist".into()); + let artist = meta + .first() + .cloned() + .unwrap_or_else(|| "Unknown artist".into()); let album = meta .get(1) .filter(|a| !a.contains(" views") && !a.contains(" plays")) @@ -180,10 +192,10 @@ pub fn parse_search_response(v: &Value) -> Vec { } fn ytdlp_search(query: &str) -> Result> { - ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}"), &[]) + ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}")) } -fn ytdlp_entries(target: &str, extra: &[&str]) -> Result> { +fn ytdlp_entries(target: &str) -> Result> { let out = Command::new("yt-dlp") .args([ "--flat-playlist", @@ -193,7 +205,6 @@ fn ytdlp_entries(target: &str, extra: &[&str]) -> Result> { "--socket-timeout", "15", ]) - .args(extra) .arg(target) .stdin(Stdio::null()) .output()