use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::SystemTime;
use crate::i18n::Text;
const YT_DLP_MAX_AGE_DAYS: i64 = 90;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Manager {
Apt,
Pacman,
Dnf,
Zypper,
Brew,
Winget,
Unknown,
}
impl Manager {
pub fn name(self) -> &'static str {
match self {
Self::Apt => "apt",
Self::Pacman => "pacman",
Self::Dnf => "dnf",
Self::Zypper => "zypper",
Self::Brew => "Homebrew",
Self::Winget => "winget",
Self::Unknown => "?",
}
}
}
pub fn manager(os: &str, os_release: &str) -> Manager {
match os {
"windows" => return Manager::Winget,
"macos" => return Manager::Brew,
_ => {}
}
let field = |key: &str| {
os_release
.lines()
.find_map(|l| l.strip_prefix(key)?.strip_prefix('='))
.map(|v| v.trim_matches('"').to_lowercase())
.unwrap_or_default()
};
let ids = format!("{} {}", field("ID"), field("ID_LIKE"));
let is = |names: &[&str]| ids.split_whitespace().any(|id| names.contains(&id));
if is(&["debian", "ubuntu"]) {
Manager::Apt
} else if is(&["arch", "manjaro", "endeavouros"]) {
Manager::Pacman
} else if is(&["fedora", "rhel", "centos"]) {
Manager::Dnf
} else if is(&["opensuse", "suse", "opensuse-tumbleweed", "opensuse-leap"]) {
Manager::Zypper
} else {
Manager::Unknown
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Mpv,
YtDlp,
Ffmpeg,
Kitty,
Xdotool,
Xwininfo,
Xprop,
Wmctrl,
Keyring,
}
impl Tool {
pub fn program(self) -> &'static str {
match self {
Self::Mpv => "mpv",
Self::YtDlp => "yt-dlp",
Self::Ffmpeg => "ffmpeg",
Self::Kitty => "kitty",
Self::Xdotool => "xdotool",
Self::Xwininfo => "xwininfo",
Self::Xprop => "xprop",
Self::Wmctrl => "wmctrl",
Self::Keyring => "keyring",
}
}
pub fn required(self) -> bool {
matches!(self, Self::Mpv | Self::YtDlp)
}
pub fn purpose(self) -> Text {
match self {
Self::Mpv => Text::new("reproduce los videos y la música", "plays videos and music"),
Self::YtDlp => Text::new("lee YouTube: listas, sesión y videos", "reads YouTube: lists, login and videos"),
Self::Ffmpeg => Text::new("descargas con video y audio juntos (d)", "downloads with video and sound (d)"),
Self::Kitty => {
Text::new("terminal con el video dentro de la app", "terminal with the video inside the app")
}
Self::Xdotool => Text::new("modo superpuesto (X11)", "overlay mode (X11)"),
Self::Xwininfo => Text::new("modo superpuesto (X11)", "overlay mode (X11)"),
Self::Xprop => Text::new("modo superpuesto (X11)", "overlay mode (X11)"),
Self::Wmctrl => Text::new("pantalla completa de la terminal (X11)", "terminal fullscreen (X11)"),
Self::Keyring => Text::new(
"llavero: guarda la clave de la sesión de Chrome/Brave",
"keyring: holds the key to Chrome/Brave's login",
),
}
}
fn package(self, manager: Manager) -> Option<&'static str> {
use Manager::*;
Some(match (self, manager) {
(_, Unknown) | (Self::Keyring, _) => return None,
(Self::Mpv, Winget) => "shinchiro.mpv",
(Self::YtDlp, Winget) => "yt-dlp.yt-dlp",
(Self::Ffmpeg, Winget) => "Gyan.FFmpeg",
(Self::Kitty | Self::Xdotool | Self::Xwininfo | Self::Xprop | Self::Wmctrl, Winget) => return None,
(Self::Xdotool | Self::Xwininfo | Self::Xprop | Self::Wmctrl, Brew) => return None,
(Self::Ffmpeg, Dnf) => "ffmpeg-free",
(Self::Xwininfo | Self::Xprop, Apt) => "x11-utils",
(Self::Xwininfo, Pacman) => "xorg-xwininfo",
(Self::Xprop, Pacman) => "xorg-xprop",
(tool, _) => tool.program(),
})
}
}
pub fn tools(os: &str) -> Vec<Tool> {
let mut tools = vec![Tool::Mpv, Tool::YtDlp, Tool::Ffmpeg];
if os != "windows" {
tools.push(Tool::Kitty);
}
if os == "linux" {
tools.extend([Tool::Xdotool, Tool::Xwininfo, Tool::Xprop, Tool::Wmctrl, Tool::Keyring]);
}
tools
}
pub fn install_command(tool: Tool, manager: Manager) -> Option<String> {
if tool == Tool::YtDlp && manager == Manager::Apt {
return Some("sudo apt install pipx && pipx install yt-dlp".into());
}
let package = tool.package(manager)?;
Some(match manager {
Manager::Apt => format!("sudo apt install {package}"),
Manager::Pacman => format!("sudo pacman -S {package}"),
Manager::Dnf => format!("sudo dnf install {package}"),
Manager::Zypper => format!("sudo zypper install {package}"),
Manager::Brew if tool == Tool::Kitty => "brew install --cask kitty".into(),
Manager::Brew => format!("brew install {package}"),
Manager::Winget => format!("winget install --id {package} -e"),
Manager::Unknown => return None,
})
}
pub fn update_yt_dlp(path: &str, manager: Manager) -> String {
if path.contains("pipx") || path.contains("/.local/bin/") {
"pipx upgrade yt-dlp".into()
} else {
match manager {
Manager::Apt => "sudo apt install pipx && pipx install yt-dlp".into(),
Manager::Pacman => "sudo pacman -Syu yt-dlp".into(),
Manager::Dnf => "sudo dnf upgrade yt-dlp".into(),
Manager::Zypper => "sudo zypper update yt-dlp".into(),
Manager::Brew => "brew upgrade yt-dlp".into(),
Manager::Winget => "winget upgrade --id yt-dlp.yt-dlp -e".into(),
Manager::Unknown => "yt-dlp -U".into(),
}
}
}
pub fn install_secretstorage(yt_dlp: &str, manager: Manager) -> String {
if yt_dlp.contains("pipx") || yt_dlp.contains("/.local/bin/") {
return "pipx inject yt-dlp secretstorage".into();
}
match manager {
Manager::Apt => "sudo apt install python3-secretstorage".into(),
Manager::Pacman => "sudo pacman -S python-secretstorage".into(),
Manager::Dnf => "sudo dnf install python3-secretstorage".into(),
Manager::Zypper => "sudo zypper install python3-SecretStorage".into(),
_ => "pip install secretstorage".into(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
pub tool: Tool,
pub found: Option<(String, Option<String>)>,
pub outdated: bool,
pub fix: Option<String>,
}
impl Check {
pub fn ok(&self) -> bool {
self.found.is_some() && !self.outdated
}
}
pub fn run() -> (Manager, Vec<Check>) {
let os = std::env::consts::OS;
let release = std::fs::read_to_string("/etc/os-release").unwrap_or_default();
let manager = manager(os, &release);
let checks = tools(os)
.into_iter()
.map(|tool| check(tool, manager))
.filter(|c| c.tool != Tool::Keyring || c.found.is_some())
.collect();
(manager, checks)
}
fn check(tool: Tool, manager: Manager) -> Check {
if tool == Tool::Keyring {
return check_keyring(manager);
}
let Some(path) = find(tool.program()) else {
return Check { tool, found: None, outdated: false, fix: install_command(tool, manager) };
};
let path = path.display().to_string();
let version = version_of(tool, &path);
let outdated =
tool == Tool::YtDlp && version.as_deref().is_some_and(|v| yt_dlp_age_days(v, today()) > YT_DLP_MAX_AGE_DAYS);
let fix = outdated.then(|| update_yt_dlp(&path, manager));
Check { tool, found: Some((path, version)), outdated, fix }
}
fn check_keyring(manager: Manager) -> Check {
let keyring = crate::session::detect_keyring();
let mut check = Check { tool: Tool::Keyring, found: None, outdated: false, fix: None };
if keyring.is_empty() {
return check;
}
if keyring.starts_with("KWALLET") {
check.outdated = find("kwallet-query").is_none();
} else if let Some(yt_dlp) = find("yt-dlp") {
let yt_dlp = yt_dlp.display().to_string();
let out = Command::new(&yt_dlp).arg("-v").stdin(Stdio::null()).output();
let text = out.map(|o| String::from_utf8_lossy(&o.stderr).into_owned()).unwrap_or_default();
if text.contains("Optional libraries") && !text.contains("secretstorage") {
check.outdated = true;
check.fix = Some(install_secretstorage(&yt_dlp, manager));
}
}
check.found = Some((keyring.clone(), Some(keyring)));
check
}
pub fn find(program: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
let names =
if cfg!(windows) { vec![format!("{program}.exe"), program.to_owned()] } else { vec![program.to_owned()] };
std::env::split_paths(&paths).flat_map(|dir| names.iter().map(move |n| dir.join(n))).find(|p| p.is_file())
}
fn version_of(tool: Tool, path: &str) -> Option<String> {
let flag = if tool == Tool::Ffmpeg { "-version" } else { "--version" };
if matches!(tool, Tool::Xdotool | Tool::Xwininfo | Tool::Xprop | Tool::Wmctrl) {
return None; }
let out = Command::new(path).arg(flag).stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?;
parse_version(tool, &String::from_utf8_lossy(&out.stdout))
}
pub fn parse_version(tool: Tool, output: &str) -> Option<String> {
let first = output.lines().next()?.trim();
let word = match tool {
Tool::YtDlp => first.split_whitespace().next(),
Tool::Ffmpeg => first.split_whitespace().nth(2),
_ => first.split_whitespace().nth(1),
}?;
Some(word.trim_start_matches('v').to_owned())
}
pub fn yt_dlp_age_days(version: &str, today: (i64, i64, i64)) -> i64 {
let mut parts = version.split('.').map(|p| p.parse::<i64>().unwrap_or(0));
let date = (parts.next().unwrap_or(0), parts.next().unwrap_or(1), parts.next().unwrap_or(1));
days(today) - days(date)
}
fn days((y, m, d): (i64, i64, i64)) -> i64 {
y * 365 + (m - 1) * 30 + d
}
fn today() -> (i64, i64, i64) {
let secs = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);
let (y, m, d) = crate::domain::civil_from_days(secs.div_euclid(86_400));
(y, i64::from(m), i64::from(d))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_manager_from_the_system() {
assert_eq!(manager("linux", "ID=ubuntu\nID_LIKE=debian\n"), Manager::Apt);
assert_eq!(manager("linux", "ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"), Manager::Apt);
assert_eq!(manager("linux", "ID=arch\n"), Manager::Pacman);
assert_eq!(manager("linux", "ID=endeavouros\nID_LIKE=arch\n"), Manager::Pacman);
assert_eq!(manager("linux", "ID=fedora\n"), Manager::Dnf);
assert_eq!(manager("linux", "ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"), Manager::Zypper);
assert_eq!(manager("linux", "ID=gentoo\n"), Manager::Unknown);
assert_eq!(manager("windows", ""), Manager::Winget);
assert_eq!(manager("macos", ""), Manager::Brew);
}
#[test]
fn install_commands_use_each_systems_names() {
assert_eq!(install_command(Tool::Mpv, Manager::Apt).unwrap(), "sudo apt install mpv");
assert_eq!(install_command(Tool::YtDlp, Manager::Apt).unwrap(), "sudo apt install pipx && pipx install yt-dlp");
assert_eq!(install_command(Tool::Xprop, Manager::Apt).unwrap(), "sudo apt install x11-utils");
assert_eq!(install_command(Tool::Xwininfo, Manager::Pacman).unwrap(), "sudo pacman -S xorg-xwininfo");
assert_eq!(install_command(Tool::Ffmpeg, Manager::Dnf).unwrap(), "sudo dnf install ffmpeg-free");
assert_eq!(install_command(Tool::Mpv, Manager::Winget).unwrap(), "winget install --id shinchiro.mpv -e");
assert_eq!(install_command(Tool::Kitty, Manager::Brew).unwrap(), "brew install --cask kitty");
assert_eq!(install_command(Tool::Kitty, Manager::Winget), None);
assert_eq!(install_command(Tool::Mpv, Manager::Unknown), None);
}
#[test]
fn tools_depend_on_the_system() {
assert_eq!(tools("windows"), [Tool::Mpv, Tool::YtDlp, Tool::Ffmpeg]);
assert!(tools("macos").contains(&Tool::Kitty) && !tools("macos").contains(&Tool::Xdotool));
assert_eq!(tools("linux").len(), 9);
assert!(tools("linux").contains(&Tool::Keyring) && !tools("macos").contains(&Tool::Keyring));
assert!(Tool::Mpv.required() && Tool::YtDlp.required() && !Tool::Kitty.required());
}
#[test]
fn secretstorage_goes_where_yt_dlp_is() {
assert_eq!(
install_secretstorage("/home/u/.local/bin/yt-dlp", Manager::Apt),
"pipx inject yt-dlp secretstorage"
);
assert_eq!(install_secretstorage("/usr/bin/yt-dlp", Manager::Apt), "sudo apt install python3-secretstorage");
assert_eq!(install_secretstorage("/usr/bin/yt-dlp", Manager::Pacman), "sudo pacman -S python-secretstorage");
}
#[test]
fn versions_and_age() {
assert_eq!(
parse_version(Tool::Mpv, "mpv v0.41.0 Copyright © 2000-2025 mpv/MPlayer/mplayer2 projects\n").unwrap(),
"0.41.0"
);
assert_eq!(parse_version(Tool::YtDlp, "2026.08.19\n").unwrap(), "2026.08.19");
assert_eq!(parse_version(Tool::Ffmpeg, "ffmpeg version 6.1.1-3ubuntu5 Copyright\n").unwrap(), "6.1.1-3ubuntu5");
assert_eq!(parse_version(Tool::Kitty, "kitty 0.39.1 created by Kovid Goyal\n").unwrap(), "0.39.1");
assert_eq!(parse_version(Tool::Mpv, ""), None);
assert_eq!(yt_dlp_age_days("2026.08.19", (2026, 10, 10)), 51);
assert!(yt_dlp_age_days("2024.04.09", (2026, 10, 10)) > YT_DLP_MAX_AGE_DAYS);
assert_eq!(update_yt_dlp("/home/u/.local/bin/yt-dlp", Manager::Apt), "pipx upgrade yt-dlp");
assert_eq!(update_yt_dlp("/usr/bin/yt-dlp", Manager::Pacman), "sudo pacman -Syu yt-dlp");
}
#[test]
fn a_check_is_ok_only_when_found_and_current() {
let found =
Check { tool: Tool::YtDlp, found: Some(("/usr/bin/yt-dlp".into(), None)), outdated: false, fix: None };
assert!(found.ok());
assert!(!Check { outdated: true, ..found.clone() }.ok());
assert!(!Check { found: None, ..found }.ok());
}
}