use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::icons::IconMode;
use crate::ids::ChannelId;
use crate::images::ImageMode;
use crate::links::{self, Link};
use crate::video::{self, Video};
use crate::ytdlp::AudioQuality;
use crate::{config, sponsorblock, theme};
const MAX_BLOCKED: usize = 1000;
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(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Repeat {
#[default]
Off,
All,
One,
}
impl Repeat {
pub const CHOICES: [Repeat; 3] = [Repeat::Off, Repeat::All, Repeat::One];
pub fn name(self) -> &'static str {
match self {
Repeat::Off => "off",
Repeat::All => "all",
Repeat::One => "one",
}
}
pub fn next(self) -> Self {
match self {
Repeat::Off => Repeat::All,
Repeat::All => Repeat::One,
Repeat::One => Repeat::Off,
}
}
}
#[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 repeat: Repeat,
pub sponsorblock: bool,
pub sponsorblock_categories: Vec<String>,
pub dearrow: bool,
pub volume: u32,
pub blocked_channels: Vec<String>,
pub blocked_words: Vec<String>,
pub descriptions: bool,
pub subtitles: bool,
pub subtitle_languages: Vec<String>,
pub list: 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,
repeat: Repeat::Off,
sponsorblock: false,
sponsorblock_categories: ["sponsor", "intro", "outro"].map(String::from).into(),
dearrow: false,
volume: 100,
blocked_channels: Vec::new(),
blocked_words: Vec::new(),
descriptions: true,
subtitles: false,
subtitle_languages: Vec::new(),
list: false,
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 = video::one_line(name, 40);
unknown.push(format!("the SponsorBlock category “{name}”"));
}
}
for entry in settings.blocked_channels.iter().take(MAX_BLOCKED) {
if matches!(blocked_channel(entry), Blocked::Unknown) {
let entry = video::one_line(entry, 40);
unknown.push(format!(
"the blocked channel “{entry}” (a handle: give its name or UC… id)"
));
}
}
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 subtitle_languages(&self) -> Vec<String> {
let system = ["LC_ALL", "LC_MESSAGES", "LANG"]
.iter()
.filter_map(|name| std::env::var(name).ok())
.find(|value| !value.is_empty());
languages(&self.subtitle_languages, system.as_deref())
}
pub fn volume(&self) -> u32 {
self.volume.min(100)
}
pub fn hides(&self, video: &Video) -> bool {
(!self.shorts && video.short)
|| (!self.live && (video.live || video.upcoming))
|| self.blocks(video)
}
pub fn blocks(&self, video: &Video) -> bool {
let channel = video.channel.to_lowercase();
let by_channel = self.blocked_channels.iter().take(MAX_BLOCKED).any(|entry| {
match blocked_channel(entry) {
Blocked::Id(id) => video.channel_id.as_ref() == Some(&id),
Blocked::Name(name) => !channel.is_empty() && name == channel,
Blocked::Unknown => false,
}
});
if by_channel {
return true;
}
let title = video.title.to_lowercase();
self.blocked_words.iter().take(MAX_BLOCKED).any(|word| {
holds_word(
&title,
&video::one_line(word, video::MAX_TITLE).to_lowercase(),
)
})
}
pub fn blocked(&self) -> (usize, usize) {
let count = |list: &[String]| {
list.iter()
.take(MAX_BLOCKED)
.filter(|entry| !entry.trim().is_empty())
.count()
};
(count(&self.blocked_channels), count(&self.blocked_words))
}
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; 28] = [
"theme",
"max_height",
"audio_quality",
"history",
"autoplay",
"repeat",
"sponsorblock",
"sponsorblock_categories",
"dearrow",
"volume",
"blocked_channels",
"blocked_words",
"descriptions",
"subtitles",
"subtitle_languages",
"list",
"jukebox_list",
"jukebox_autoremove",
"shorts",
"live",
"refresh_minutes",
"images",
"icons",
"card_width",
"sidebar_width",
"yt_dlp",
"mpv",
"deno",
];
const RETIRED: [&str; 1] = ["refresh_hours"];
enum Blocked {
Id(ChannelId),
Name(String),
Unknown,
}
fn blocked_channel(entry: &str) -> Blocked {
let entry = entry.trim();
if let Some(id) = ChannelId::parse(entry) {
return Blocked::Id(id);
}
match links::parse(entry) {
Some(Link::Channel(id)) => return Blocked::Id(id),
Some(Link::Named(_)) => return Blocked::Unknown,
_ => {}
}
if entry.starts_with('@') {
return Blocked::Unknown;
}
Blocked::Name(video::one_line(entry, video::MAX_CHANNEL).to_lowercase())
}
fn holds_word(text: &str, word: &str) -> bool {
let runs_on = |c: char| {
c.is_alphanumeric()
&& !matches!(
u32::from(c),
0x0E00..=0x109F | 0x1780..=0x17FF | 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFF66..=0xFF9F
)
};
let (Some(first), Some(last)) = (word.chars().next(), word.chars().next_back()) else {
return false;
};
text.match_indices(word).any(|(i, _)| {
let before = text[..i].chars().next_back();
let after = text[i + word.len()..].chars().next();
!(before.is_some_and(runs_on) && runs_on(first))
&& !(after.is_some_and(runs_on) && runs_on(last))
})
}
fn languages(set: &[String], system: Option<&str>) -> Vec<String> {
let code = |s: &str| {
let parts: Vec<&str> = s.split('-').collect();
(1..=3).contains(&parts.len())
&& (2..=3).contains(&parts[0].len())
&& parts[0].bytes().all(|b| b.is_ascii_alphabetic())
&& parts[1..]
.iter()
.all(|p| (2..=8).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_alphanumeric()))
};
let mut wanted: Vec<String> = set.iter().filter(|s| code(s)).cloned().collect();
if wanted.is_empty() {
let spoken = system
.and_then(|locale| locale.split(['_', '.', '@']).next())
.filter(|lang| code(lang) && *lang != "C" && *lang != "POSIX")
.map(str::to_lowercase);
wanted.extend(spoken);
if !wanted.iter().any(|lang| lang == "en") {
wanted.push("en".into());
}
}
wanted.truncate(8);
wanted
}
pub fn path(data_dir: &Path) -> PathBuf {
data_dir.join("settings.toml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subtitles_come_in_the_languages_asked_for_or_this_computers() {
let set = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(languages(&[], Some("fr_FR.UTF-8")), ["fr", "en"]);
assert_eq!(languages(&[], Some("en_GB.UTF-8")), ["en"]);
assert_eq!(languages(&[], Some("C")), ["en"]);
assert_eq!(languages(&[], None), ["en"]);
assert_eq!(
languages(&set(&["pt-BR", "not a code", "de"]), Some("fr_FR")),
["pt-BR", "de"]
);
}
#[test]
fn blocked_channels_and_words_hide_their_videos() {
let video = |title: &str, channel: &str, id: &str| Video {
title: title.into(),
channel: channel.into(),
channel_id: ChannelId::parse(id),
..Video::placeholder(crate::ids::VideoId::parse("dQw4w9WgXcQ").unwrap(), false)
};
let id = "UCsBjURrPoezykLs9EqgamOA";
let settings = Settings {
blocked_channels: [
" fireship ",
"https://www.youtube.com/channel/UCX6OQ3DkcsbYNE6H8uQQuVA",
"@somehandle",
"",
]
.map(String::from)
.into(),
blocked_words: ["minecraft", "Ai", "", " ", "you won't believe", "原神"]
.map(String::from)
.into(),
..Settings::default()
};
let shown =
|title: &str, channel: &str, id: &str| !settings.hides(&video(title, channel, id));
assert!(
!shown("Rust in 100 seconds", "Fireship", id),
"by name, any case"
);
assert!(
!shown("A video", "MrBeast", "UCX6OQ3DkcsbYNE6H8uQQuVA"),
"by a link's id"
);
assert!(shown("A video", "", id), "no name to match");
assert!(!shown("MINECRAFT speedrun", "Someone", id));
assert!(!shown("Speedrun (#minecraft)", "Someone", id));
assert!(shown("Minecrafter reacts", "Someone", id), "whole words");
assert!(
shown("How to maintain a bike", "Someone", id),
"ai isn't in maintain"
);
assert!(!shown("Will AI take over?", "Someone", id));
assert!(!shown("You won't believe this", "Someone", id), "a phrase");
assert!(
!shown("【原神】新キャラ", "Someone", id),
"no spaces in Japanese"
);
assert!(
shown("Rust in 100 seconds", "Someone", id),
"empty words match nothing"
);
assert_eq!(settings.blocked(), (3, 4));
assert!(!Settings::default().blocks(&video("Anything", "Anyone", id)));
}
#[test]
fn a_blocked_handle_is_reported_as_unknown() {
let dir = std::env::temp_dir().join(format!("tuitube-blocked-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = path(&dir);
std::fs::write(
&file,
"blocked_channels = [\"@foo\", \"youtube.com/@bar\", \"Fireship\", \"UCsBjURrPoezykLs9EqgamOA\"]\nblocked_words = [\"ai\"]\nvolume = 250\ndearrow = true\n",
)
.unwrap();
let (settings, unknown) = Settings::load_checked(&file).unwrap();
assert_eq!(unknown.len(), 2, "{unknown:?}");
assert!(unknown[0].contains("“@foo”"), "{unknown:?}");
assert!(settings.dearrow);
assert_eq!(settings.volume(), 100, "at most 100");
assert_eq!(Settings::default().volume(), 100);
assert!(!Settings::default().dearrow, "opt-in");
std::fs::remove_dir_all(&dir).unwrap();
}
#[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();
}
}