feat: add categorized channel subscription feeds
This commit is contained in:
+380
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user