use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::i18n::{Lang, Text};
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub session: SessionConfig,
pub player: PlayerConfig,
pub ui: UiConfig,
pub search: SearchConfig,
pub download: DownloadConfig,
pub media: MediaConfig,
pub effects: EffectsConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct MediaConfig {
pub video_dirs: Vec<String>,
pub music_dirs: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SessionConfig {
pub browser: String,
pub keyring: String,
pub cookies_file: String,
pub asked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PlayerConfig {
pub mode: PlayerMode,
pub max_height: u32,
pub prefer_codec: String,
pub hwdec: bool,
pub language: Language,
pub sub_lang: String,
pub background_audio: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PlayerMode {
Auto,
Embedded,
#[default]
Window,
Overlay,
}
impl PlayerMode {
pub fn next(self) -> Self {
match self {
Self::Auto => Self::Embedded,
Self::Embedded => Self::Window,
Self::Window => Self::Overlay,
Self::Overlay => Self::Auto,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Embedded => "embedded",
Self::Window => "window",
Self::Overlay => "overlay",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Language {
#[default]
Subtitles,
Original,
Dubbed,
}
impl Language {
pub fn next(self) -> Self {
match self {
Self::Subtitles => Self::Original,
Self::Original => Self::Dubbed,
Self::Dubbed => Self::Subtitles,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Subtitles => "subtitles",
Self::Original => "original",
Self::Dubbed => "dubbed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct UiConfig {
pub sidebar: bool,
pub max_tabs: usize,
pub icons: IconSet,
pub lang: Option<Lang>,
pub thumbnails: bool,
pub mouse: bool,
pub check_updates: bool,
pub hide_watched: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EffectsConfig {
pub startup: bool,
pub pages: bool,
pub dialogs: bool,
}
impl Default for EffectsConfig {
fn default() -> Self {
Self { startup: true, pages: true, dialogs: true }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IconSet {
#[default]
Emoji,
Nerd,
Unicode,
}
impl IconSet {
pub fn next(self) -> Self {
match self {
Self::Emoji => Self::Nerd,
Self::Nerd => Self::Unicode,
Self::Unicode => Self::Emoji,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Emoji => "emoji",
Self::Nerd => "nerd",
Self::Unicode => "unicode",
}
}
}
pub const LANGUAGES: [(&str, Text); 9] = [
("es", Text::new("español", "Spanish")),
("en", Text::new("inglés", "English")),
("pt", Text::new("portugués", "Portuguese")),
("fr", Text::new("francés", "French")),
("it", Text::new("italiano", "Italian")),
("de", Text::new("alemán", "German")),
("ja", Text::new("japonés", "Japanese")),
("ko", Text::new("coreano", "Korean")),
("ru", Text::new("ruso", "Russian")),
];
pub fn next_language(code: &str) -> &'static str {
let index = LANGUAGES.iter().position(|(c, _)| *c == code).map_or(0, |i| (i + 1) % LANGUAGES.len());
LANGUAGES[index].0
}
pub fn language_name(code: &str, lang: Lang) -> String {
LANGUAGES
.iter()
.find(|(c, _)| *c == code)
.map_or_else(|| code.to_owned(), |(c, name)| format!("{} ({c})", name.get(lang)))
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Wallet {
pub network: String,
pub address: String,
}
pub fn donations() -> Vec<Wallet> {
parse_donations(include_str!("../donate.toml"))
}
fn parse_donations(text: &str) -> Vec<Wallet> {
#[derive(Deserialize)]
struct File {
#[serde(default)]
wallet: Vec<Wallet>,
}
let wallets = toml::from_str::<File>(text).map(|f| f.wallet).unwrap_or_default();
wallets.into_iter().filter(|w| !w.address.trim().is_empty()).collect()
}
pub const QUALITIES: [u32; 5] = [360, 480, 720, 1080, 1440];
pub fn next_quality(current: u32) -> u32 {
QUALITIES.iter().copied().find(|q| *q > current).unwrap_or(QUALITIES[0])
}
pub const CODECS: [(&str, &str); 4] = [("avc1", "H.264"), ("vp", "VP9"), ("av01", "AV1"), ("", "")];
pub fn next_codec(current: &str) -> &'static str {
let at = CODECS.iter().position(|(c, _)| *c == current);
CODECS[at.map_or(0, |i| (i + 1) % CODECS.len())].0
}
pub fn codec_name(codec: &str, lang: Lang) -> &str {
match CODECS.iter().find(|(c, _)| *c == codec) {
Some((_, "")) => lang.pick("cualquiera", "any"),
Some((_, name)) => name,
None => codec,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SearchConfig {
pub results: u32,
pub topics: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DownloadConfig {
pub dir: String,
}
impl Default for PlayerConfig {
fn default() -> Self {
Self {
mode: PlayerMode::Window,
max_height: 720,
prefer_codec: "avc1".into(),
hwdec: true,
language: Language::Subtitles,
sub_lang: "es".into(),
background_audio: true,
}
}
}
impl Default for UiConfig {
fn default() -> Self {
Self {
sidebar: true,
max_tabs: 9,
icons: IconSet::Emoji,
lang: None,
thumbnails: true,
mouse: true,
check_updates: true,
hide_watched: true,
}
}
}
impl Default for SearchConfig {
fn default() -> Self {
Self { results: 60, topics: Vec::new() }
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("cannot read {path}: {source}")]
Read { path: PathBuf, source: std::io::Error },
#[error("invalid config {path}: {source}")]
Parse { path: PathBuf, source: toml::de::Error },
#[error("cannot update {path}: {reason}")]
Write { path: PathBuf, reason: String },
}
impl Config {
pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
toml::from_str(text)
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(source) => return Err(ConfigError::Read { path: path.into(), source }),
};
Self::from_toml(&text).map_err(|source| ConfigError::Parse { path: path.into(), source })
}
pub fn default_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "tubetui").map(|dirs| dirs.config_dir().join("config.toml"))
}
}
pub fn set_value(path: &Path, table: &str, key: &str, value: impl Into<toml_edit::Value>) -> Result<(), ConfigError> {
let error = |reason: String| ConfigError::Write { path: path.into(), reason };
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(error(e.to_string())),
};
let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| error(e.to_string()))?;
let section = doc
.entry(table)
.or_insert_with(toml_edit::table)
.as_table_mut()
.ok_or_else(|| error(format!("`{table}` is not a table")))?;
section[key] = toml_edit::Item::Value(value.into());
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| error(e.to_string()))?;
}
std::fs::write(path, doc.to_string()).map_err(|e| error(e.to_string()))
}
#[cfg(test)]
pub(crate) fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tubetui-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults() {
let c = Config::default();
assert_eq!(c.session.browser, "");
assert_eq!(c.session.cookies_file, "");
assert_eq!(c.player.mode, PlayerMode::Window);
assert_eq!(c.player.max_height, 720);
assert_eq!(c.player.prefer_codec, "avc1");
assert!(c.player.hwdec);
assert_eq!(c.player.language, Language::Subtitles);
assert_eq!(c.player.sub_lang, "es");
assert!(c.ui.sidebar);
assert_eq!(c.ui.max_tabs, 9);
assert_eq!(c.ui.icons, IconSet::Emoji);
assert_eq!(c.ui.lang, None, "follows the system until chosen");
assert_eq!(c.effects, EffectsConfig { startup: true, pages: true, dialogs: true });
assert_eq!(c.search.results, 60);
assert_eq!(c.download.dir, "");
}
#[test]
fn donations_skip_empty_wallets() {
let text = "[[wallet]]\nnetwork = \"USDT (TRC20)\"\naddress = \"TXyz\"\n[[wallet]]\nnetwork = \"BTC\"\naddress = \"\"\n";
assert_eq!(parse_donations(text), vec![Wallet { network: "USDT (TRC20)".into(), address: "TXyz".into() }]);
assert_eq!(parse_donations(""), vec![]);
donations(); assert!(toml::from_str::<toml::Table>(include_str!("../donate.toml")).is_ok());
}
#[test]
fn empty_file_is_default() {
assert_eq!(Config::from_toml("").unwrap(), Config::default());
}
#[test]
fn partial_file_keeps_other_defaults() {
let c = Config::from_toml("[player]\nmode = \"window\"\nmax_height = 480\n").unwrap();
assert_eq!(c.player.mode, PlayerMode::Window);
assert_eq!(c.player.max_height, 480);
assert_eq!(c.player.prefer_codec, "avc1");
assert_eq!(c.ui, UiConfig::default());
}
#[test]
fn enums_use_lowercase_names() {
let c = Config::from_toml("[ui]\nicons = \"nerd\"\nlang = \"en\"\n[player]\nlanguage = \"dubbed\"\n").unwrap();
assert_eq!(c.ui.icons, IconSet::Nerd);
assert_eq!(c.ui.lang, Some(Lang::En));
assert_eq!(c.player.language, Language::Dubbed);
assert_eq!(Language::Dubbed.as_str(), "dubbed");
}
#[test]
fn cycles_wrap_around() {
assert_eq!(PlayerMode::Auto.next().next().next().next(), PlayerMode::Auto);
assert_eq!(PlayerMode::Window.next().as_str(), "overlay");
assert_eq!(PlayerMode::Embedded.as_str(), "embedded");
assert_eq!(IconSet::Emoji.next(), IconSet::Nerd);
assert_eq!(IconSet::Unicode.next(), IconSet::Emoji);
assert_eq!(IconSet::Nerd.as_str(), "nerd");
assert_eq!(next_quality(480), 720);
assert_eq!(next_quality(1440), 360);
assert_eq!(next_codec("avc1"), "vp");
assert_eq!(next_codec(""), "avc1");
assert_eq!(next_codec("hev1"), "avc1");
assert_eq!(codec_name("", Lang::En), "any");
assert_eq!(codec_name("av01", Lang::Es), "AV1");
assert_eq!(next_quality(600), 720);
}
#[test]
fn languages_cycle_and_have_names() {
assert_eq!(next_language("es"), "en");
assert_eq!(next_language("ru"), "es");
assert_eq!(next_language("xx"), "es");
assert_eq!(language_name("pt", Lang::Es), "portugués (pt)");
assert_eq!(language_name("pt", Lang::En), "Portuguese (pt)");
assert_eq!(language_name("eu", Lang::En), "eu");
}
#[test]
fn media_folders_are_lists() {
let c = Config::from_toml("[media]\nvideo_dirs = [\"~/Videos\", \"/mnt/pelis\"]\n").unwrap();
assert_eq!(c.media.video_dirs, ["~/Videos", "/mnt/pelis"]);
assert!(c.media.music_dirs.is_empty());
let path = temp_dir("lists").join("config.toml");
let list: toml_edit::Array = ["~/Música", "/mnt/discos"].into_iter().collect();
set_value(&path, "media", "music_dirs", list).unwrap();
assert_eq!(Config::load(&path).unwrap().media.music_dirs, ["~/Música", "/mnt/discos"]);
}
#[test]
fn set_value_writes_numbers_as_numbers() {
let path = temp_dir("number").join("config.toml");
set_value(&path, "player", "max_height", 1080_i64).unwrap();
assert_eq!(Config::load(&path).unwrap().player.max_height, 1080);
}
#[test]
fn language_cycles() {
assert_eq!(Language::Subtitles.next(), Language::Original);
assert_eq!(Language::Original.next(), Language::Dubbed);
assert_eq!(Language::Dubbed.next(), Language::Subtitles);
}
#[test]
fn typos_are_rejected() {
assert!(Config::from_toml("[player]\nmax_heigth = 480\n").is_err());
assert!(Config::from_toml("[player]\nmode = \"fullscreen\"\n").is_err());
}
#[test]
fn serializes_back_to_equal_config() {
let c = Config::default();
let text = toml::to_string(&c).unwrap();
assert_eq!(Config::from_toml(&text).unwrap(), c);
}
#[test]
fn missing_file_loads_defaults() {
let c = Config::load(Path::new("/definitely/not/here/config.toml")).unwrap();
assert_eq!(c, Config::default());
}
#[test]
fn invalid_file_reports_path() {
let path = temp_dir("invalid").join("config.toml");
std::fs::write(&path, "[ui]\nsidebar = 3\n").unwrap();
let err = Config::load(&path).unwrap_err().to_string();
assert!(err.contains("config.toml"), "{err}");
}
#[test]
fn default_path_ends_in_config_toml() {
if let Some(p) = Config::default_path() {
assert!(p.ends_with("config.toml") && p.to_string_lossy().contains("tubetui"), "{p:?}");
}
}
#[test]
fn set_value_creates_missing_file() {
let path = temp_dir("create").join("sub/config.toml");
set_value(&path, "session", "browser", "brave").unwrap();
assert_eq!(Config::load(&path).unwrap().session.browser, "brave");
}
#[test]
fn set_value_keeps_comments_and_other_keys() {
let path = temp_dir("keep").join("config.toml");
std::fs::write(&path, "# mi config\n[player]\nmax_height = 480 # bajo consumo\n").unwrap();
set_value(&path, "player", "language", "dubbed").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("# mi config") && text.contains("# bajo consumo"), "{text}");
let c = Config::from_toml(&text).unwrap();
assert_eq!(c.player.max_height, 480);
assert_eq!(c.player.language, Language::Dubbed);
}
}