use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config;
use crate::icons::IconMode;
use crate::images::ImageMode;
use crate::video::Video;
use crate::ytdlp::AudioQuality;
use crate::{sponsorblock, theme};
pub const HEIGHTS: [u32; 6] = [2160, 1440, 1080, 720, 480, 360];
pub fn height_label(height: u32) -> String {
match height {
2160 => "Up to 2160p (4K)".into(),
1080 => "Up to 1080p (HD)".into(),
360 => "Up to 360p, to save data".into(),
h => format!("Up to {h}p"),
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub theme: String,
pub max_height: u32,
pub audio_quality: AudioQuality,
pub history: bool,
pub autoplay: bool,
pub sponsorblock: bool,
pub sponsorblock_categories: Vec<String>,
pub descriptions: bool,
pub jukebox_list: bool,
pub jukebox_autoremove: bool,
pub shorts: bool,
pub live: bool,
pub refresh_minutes: u32,
pub images: ImageMode,
pub icons: IconMode,
pub card_width: u16,
pub sidebar_width: u16,
pub yt_dlp: Option<PathBuf>,
pub mpv: Option<PathBuf>,
pub deno: Option<PathBuf>,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: theme::DEFAULT.into(),
max_height: 1080,
audio_quality: AudioQuality::High,
history: true,
autoplay: true,
sponsorblock: false,
sponsorblock_categories: ["sponsor", "intro", "outro"].map(String::from).into(),
descriptions: true,
jukebox_list: false,
jukebox_autoremove: false,
shorts: true,
live: true,
refresh_minutes: 30,
images: ImageMode::Auto,
icons: IconMode::Auto,
card_width: 34,
sidebar_width: 26,
yt_dlp: None,
mpv: None,
deno: None,
}
}
}
impl Settings {
#[cfg(test)]
pub fn load(path: &Path) -> Result<Self> {
Ok(Self::load_checked(path)?.0)
}
pub fn load_checked(path: &Path) -> Result<(Self, Vec<String>)> {
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(), Vec::new()));
}
Err(e) => {
return Err(e).with_context(|| format!("cannot read {}", config::shown(path)));
}
};
let shown = config::shown(path);
let table: toml::Table =
toml::from_str(&text).with_context(|| format!("{shown} is invalid"))?;
let mut unknown: Vec<String> = table
.keys()
.filter(|key| !KNOWN.contains(&key.as_str()) && !RETIRED.contains(&key.as_str()))
.map(|key| crate::text::clean(key))
.collect();
let settings: Self =
toml::from_str(&text).with_context(|| format!("{shown} is invalid"))?;
for name in &settings.sponsorblock_categories {
if sponsorblock::category(name).is_none() {
let name = crate::video::one_line(name, 40);
unknown.push(format!("the SponsorBlock category “{name}”"));
}
}
Ok((settings, unknown))
}
pub fn card_width(&self) -> u16 {
self.card_width.clamp(20, 80)
}
pub fn max_height(&self) -> u32 {
self.max_height.clamp(144, 4320)
}
pub fn height_choice(&self) -> Option<usize> {
HEIGHTS.iter().position(|&h| h <= self.max_height())
}
pub fn refresh_every(&self) -> i64 {
i64::from(self.refresh_minutes.clamp(15, 7 * 24 * 60)) * 60
}
pub fn image_mode(&self) -> ImageMode {
config::var("TT_IMAGES")
.and_then(|v| ImageMode::parse(&v))
.unwrap_or(self.images)
}
pub fn icon_mode(&self) -> IconMode {
config::var("TT_ICONS")
.and_then(|v| IconMode::parse(&v))
.unwrap_or(self.icons)
}
pub fn skip_categories(&self) -> Vec<&'static str> {
sponsorblock::CATEGORIES
.iter()
.map(|(c, _)| *c)
.filter(|c| self.sponsorblock_categories.iter().any(|n| n == c))
.collect()
}
pub fn hides(&self, video: &Video) -> bool {
(!self.shorts && video.short) || (!self.live && (video.live || video.upcoming))
}
pub fn sidebar_width(&self) -> u16 {
self.sidebar_width.clamp(16, 50)
}
pub fn save_one(path: &Path, key: &str, value: toml::Value) -> Result<Self> {
let shown = config::shown(path);
let mut table: toml::Table = match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text)
.with_context(|| format!("{shown} is invalid, so it wasn't changed"))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => toml::Table::new(),
Err(e) => return Err(e).with_context(|| format!("cannot read {shown}")),
};
table.insert(key.to_string(), value);
let text = toml::to_string(&table)?;
let settings: Self = toml::from_str(&text)?;
write_private(path, &text).with_context(|| format!("cannot write {shown}"))?;
Ok(settings)
}
}
fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
let new = path.with_extension(format!("toml.{}.new", std::process::id()));
let write = || -> std::io::Result<()> {
let _ = std::fs::remove_file(&new);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let mut file = options.open(&new)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
std::fs::rename(&new, path)
};
write()
}
const KNOWN: [&str; 20] = [
"theme",
"max_height",
"audio_quality",
"history",
"autoplay",
"sponsorblock",
"sponsorblock_categories",
"descriptions",
"jukebox_list",
"jukebox_autoremove",
"shorts",
"live",
"refresh_minutes",
"images",
"icons",
"card_width",
"sidebar_width",
"yt_dlp",
"mpv",
"deno",
];
const RETIRED: [&str; 1] = ["refresh_hours"];
pub fn path(data_dir: &Path) -> PathBuf {
data_dir.join("settings.toml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saving_one_setting_keeps_what_the_file_gained_since_start() {
let dir = std::env::temp_dir().join(format!("tuitube-save-one-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = path(&dir);
std::fs::write(&file, "sponsorblock = true\n").unwrap();
std::fs::write(
&file,
"history = false\nsponsorblock = false\nmpv = \"/opt/x/mpv\"\nhistroy = 1\n",
)
.unwrap();
let saved = Settings::save_one(&file, "autoplay", false.into()).unwrap();
assert!(!saved.history && !saved.sponsorblock && !saved.autoplay);
let (loaded, unknown) = Settings::load_checked(&file).unwrap();
assert_eq!(loaded, saved);
assert_eq!(loaded.mpv.as_deref(), Some(Path::new("/opt/x/mpv")));
assert_eq!(unknown, ["histroy"], "an unknown name stays to be reported");
std::fs::write(&file, "history = \"no\"").unwrap();
assert!(Settings::save_one(&file, "theme", "latte".into()).is_err());
assert_eq!(std::fs::read_to_string(&file).unwrap(), "history = \"no\"");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn missing_file_means_defaults_and_saving_round_trips() {
let dir = std::env::temp_dir().join(format!("tuitube-settings-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = path(&dir);
let _ = std::fs::remove_file(&file);
assert_eq!(Settings::load(&file).unwrap(), Settings::default());
let settings = Settings {
theme: "latte".into(),
max_height: 720,
audio_quality: AudioQuality::Low,
..Settings::default()
};
Settings::save_one(&file, "theme", "latte".into()).unwrap();
Settings::save_one(&file, "audio_quality", "low".into()).unwrap();
let saved = Settings::save_one(&file, "max_height", 720.into()).unwrap();
assert_eq!(saved, settings, "the file's settings come back");
assert_eq!(Settings::load(&file).unwrap(), settings);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&file).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "only yours");
}
std::fs::write(&file, "card_width = 2").unwrap();
assert_eq!(Settings::load(&file).unwrap().card_width(), 20);
for (height, choice) in [
(900, Some(3)),
(99_999, Some(0)),
(1080, Some(2)),
(200, None),
] {
std::fs::write(&file, format!("max_height = {height}")).unwrap();
assert_eq!(Settings::load(&file).unwrap().height_choice(), choice);
}
std::fs::write(&file, "audio_quality = \"best\"").unwrap();
assert!(Settings::load(&file).is_err(), "not one tuitube knows");
std::fs::write(&file, "histroy = false\nrefresh_hours = 72\nhistory = true").unwrap();
let (settings, unknown) = Settings::load_checked(&file).unwrap();
assert_eq!(
unknown,
["histroy"],
"a typo is reported, an old setting isn't"
);
assert!(settings.history);
std::fs::write(
&file,
"sponsorblock = true\nsponsorblock_categories = [\"outro\", \"sponsr\", \"sponsor\", \"outro\"]",
)
.unwrap();
let (settings, unknown) = Settings::load_checked(&file).unwrap();
assert_eq!(unknown, ["the SponsorBlock category “sponsr”"]);
assert_eq!(settings.skip_categories(), ["sponsor", "outro"]);
assert!(!Settings::default().sponsorblock, "opt-in");
std::fs::remove_dir_all(&dir).unwrap();
}
}