feat: add categorized channel subscription feeds

This commit is contained in:
2026-10-09 21:33:31 +02:00
parent 3954de092e
commit 656474df5b
5 changed files with 1107 additions and 84 deletions
+380
View File
@@ -0,0 +1,380 @@
use std::{
collections::BTreeMap,
ffi::OsString,
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use toml_edit::{Array, DocumentMut, Item, Table, value};
#[derive(Default, Debug)]
pub struct Config {
pub categories: BTreeMap<String, Vec<String>>,
}
pub fn config_path() -> io::Result<PathBuf> {
resolve_path(
std::env::var_os("XDG_CONFIG_HOME"),
std::env::var_os("HOME"),
)
}
fn resolve_path(xdg: Option<OsString>, home: Option<OsString>) -> io::Result<PathBuf> {
let root = match xdg.filter(|path| !path.is_empty() && Path::new(path).is_absolute()) {
Some(path) => PathBuf::from(path),
None => PathBuf::from(
home.filter(|path| !path.is_empty())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is unavailable"))?,
)
.join(".config"),
};
Ok(root.join("tubular/config.toml"))
}
pub fn load(path: &Path) -> io::Result<Config> {
let (_, document) = read_document(path)?;
parse_categories(&document)
}
pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Config> {
let category = category_name(category)?;
let channel = normalize_channel(channel)?;
let (original, mut document) = read_document(path)?;
// Validate every category before touching the document or creating directories.
let mut config = parse_categories(&document)?;
if !document.contains_key("categories") {
document.insert("categories", Item::Table(Table::new()));
}
let categories = document["categories"]
.as_table_like_mut()
.expect("categories were validated");
// Keep the manually written key and its formatting if it has surrounding spaces.
let key = categories
.iter()
.find(|(key, _)| key.trim() == category)
.map(|(key, _)| key.to_owned())
.unwrap_or_else(|| category.to_owned());
if !categories.contains_key(&key) {
categories.insert(&key, value(Array::new()));
}
let channels = categories
.get_mut(&key)
.and_then(Item::as_array_mut)
.expect("category was validated");
let saved_channels = config.categories.entry(category.to_owned()).or_default();
// Preserve manually written entries and their comments; do not append an equivalent URL.
if !saved_channels.contains(&channel) {
channels.push(channel.clone());
saved_channels.push(channel);
}
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(config)
}
fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
fn category_name(name: &str) -> io::Result<&str> {
if name.chars().any(char::is_control) || name.trim().is_empty() {
return Err(invalid(
"Category names must be nonempty and contain no control characters",
));
}
Ok(name.trim())
}
fn normalize_channel(input: &str) -> io::Result<String> {
if input.chars().any(char::is_control) {
return Err(invalid(
"Channel addresses must contain no control characters",
));
}
let input = input.trim();
let url_address = input
.strip_prefix("https://www.youtube.com/")
.or_else(|| input.strip_prefix("https://youtube.com/"));
let address = url_address.unwrap_or(input);
if let Some(handle) = address.strip_prefix('@') {
let length = handle.chars().count();
if length == 0
|| length > 30
|| !handle
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
|| !handle.starts_with(char::is_alphanumeric)
|| !handle.ends_with(char::is_alphanumeric)
{
return Err(invalid("Invalid YouTube channel handle"));
}
return Ok(format!("https://www.youtube.com/@{handle}"));
}
let id = match url_address {
Some(address) => address
.strip_prefix("channel/")
.ok_or_else(|| invalid("Expected a YouTube channel URL"))?,
None => address,
};
if id.len() != 24
|| !id.starts_with("UC")
|| !id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-')
{
return Err(invalid(
"Expected a YouTube @handle or canonical UC channel ID",
));
}
Ok(format!("https://www.youtube.com/channel/{id}"))
}
fn read_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn read_document(path: &Path) -> io::Result<(Option<Vec<u8>>, DocumentMut)> {
let bytes = read_bytes(path)?;
let document = match &bytes {
Some(bytes) => std::str::from_utf8(bytes)
.map_err(|error| invalid(format!("Config is not UTF-8: {error}")))?
.parse::<DocumentMut>()
.map_err(|error| invalid(format!("Invalid config TOML: {error}")))?,
None => DocumentMut::new(),
};
Ok((bytes, document))
}
fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
let mut config = Config::default();
let Some(item) = document.get("categories") else {
return Ok(config);
};
let categories = item
.as_table_like()
.ok_or_else(|| invalid("categories must be a TOML table"))?;
for (name, channels) in categories.iter() {
let name = category_name(name)?;
let channels = channels
.as_array()
.ok_or_else(|| invalid(format!("Category {name:?} must be an array of channels")))?;
let mut normalized = Vec::with_capacity(channels.len());
for channel in channels.iter() {
let channel = normalize_channel(
channel
.as_str()
.ok_or_else(|| invalid("Channels must be strings"))?,
)?;
if !normalized.contains(&channel) {
normalized.push(channel);
}
}
if config
.categories
.insert(name.to_owned(), normalized)
.is_some()
{
return Err(invalid(format!(
"Duplicate category name after trimming: {name:?}"
)));
}
}
Ok(config)
}
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
fn save(path: &Path, original: Option<&[u8]>, contents: &[u8]) -> io::Result<()> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let filename = path
.file_name()
.ok_or_else(|| invalid("Config path has no filename"))?;
fs::create_dir_all(parent)?;
let (temporary, mut file) = loop {
let mut name = OsString::from(".");
name.push(filename);
name.push(format!(
".{}.{}.tmp",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
let temporary = parent.join(name);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&temporary) {
Ok(file) => break (temporary, file),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
};
let result = (|| {
if original.is_some() {
file.set_permissions(fs::metadata(path)?.permissions())?;
}
file.write_all(contents)?;
file.sync_all()?;
if read_bytes(path)?.as_deref() != original {
return Err(io::Error::other(
"Config changed while saving; please retry",
));
}
if original.is_none() {
// Unlike rename, hard_link cannot overwrite a file created by a racing editor.
fs::hard_link(&temporary, path)?;
} else {
// std has no compare-and-swap rename; check immediately before replacement.
fs::rename(&temporary, path)?;
}
Ok(())
})();
drop(file);
let _ = fs::remove_file(&temporary);
result
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
loop {
let path = std::env::temp_dir().join(format!(
"tubular-config-test-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
match fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("Cannot create test directory: {error}"),
}
}
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
let directory = TestDirectory::new();
let path = directory.0.join("nested/config.toml");
assert!(load(&path).unwrap().categories.is_empty());
assert!(!path.parent().unwrap().exists());
let config = add_channel(&path, " Music ", "@example").unwrap();
assert_eq!(
config.categories["Music"],
["https://www.youtube.com/@example"]
);
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
fs::write(&path, manual).unwrap();
let config = add_channel(&path, "Music", "https://youtube.com/@example").unwrap();
assert_eq!(config.categories["Music"].len(), 1);
assert!(config.categories["Empty"].is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
let id = "UCabcdefghijklmnopqrstuv";
let config = add_channel(&path, "Music", id).unwrap();
assert_eq!(
config.categories["Music"][1],
format!("https://www.youtube.com/channel/{id}")
);
let saved = fs::read_to_string(&path).unwrap();
for comment in [
"# manual preferences",
"# keep me",
"# favorites",
"# channel note",
] {
assert!(saved.contains(comment));
}
assert_eq!(
saved.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
Some(42)
);
for malformed in [
"[categories\n",
"categories = 3\n",
"[categories]\nMusic = [3]\n",
"[categories]\nMusic = ['https://example.com/@example']\n",
"[categories]\nMusic = []\n' Music ' = []\n",
] {
fs::write(&path, malformed).unwrap();
assert!(load(&path).is_err());
assert!(add_channel(&path, "Music", "@example").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
}
fs::write(&path, manual).unwrap();
assert!(add_channel(&path, "Music", "https://www.youtube.com/@example/videos").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
}
#[test]
fn paths_and_channel_addresses_are_strict() {
let home = Some(OsString::from("/home/test"));
let fallback = PathBuf::from("/home/test/.config/tubular/config.toml");
for xdg in [
None,
Some(OsString::new()),
Some(OsString::from("relative")),
] {
assert_eq!(resolve_path(xdg, home.clone()).unwrap(), fallback);
}
assert_eq!(
resolve_path(Some("/config".into()), None).unwrap(),
PathBuf::from("/config/tubular/config.toml")
);
assert!(resolve_path(None, None).is_err());
assert!(resolve_path(None, Some(OsString::new())).is_err());
for channel in [
"",
"@",
"@bad/name",
"@bad?query",
"@bad#fragment",
"@bad\n",
"https://www.youtube.com.evil/@example",
"http://www.youtube.com/@example",
"https://www.youtube.com/channel/UCshort",
"channel/UCshort",
"https://www.youtube.com/@example/videos",
"https://www.youtube.com/@example?x=1",
"https://www.youtube.com/@example/../other",
"https://www.youtube.com/@example//",
] {
assert!(normalize_channel(channel).is_err(), "accepted {channel:?}");
}
assert_eq!(
normalize_channel("@example").unwrap(),
"https://www.youtube.com/@example"
);
assert_eq!(
normalize_channel("https://youtube.com/channel/UCabcdefghijklmnopqrstuv").unwrap(),
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"
);
assert!(category_name(" \t ").is_err());
}
}