feat: save readable channel handles with stable URL mappings

This commit is contained in:
2026-10-09 23:07:32 +02:00
parent b48682d133
commit 20cdd8afb2
3 changed files with 207 additions and 26 deletions
+151 -18
View File
@@ -35,15 +35,40 @@ fn resolve_path(xdg: Option<OsString>, home: Option<OsString>) -> io::Result<Pat
pub fn load(path: &Path) -> io::Result<Config> {
let (_, document) = read_document(path)?;
parse_categories(&document)
parse_categories(&document, &parse_channels(&document)?)
}
pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Config> {
pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> io::Result<Config> {
let category = category_name(category)?;
let handle = normalize_handle(handle)?;
let channel = normalize_channel(channel)?;
if !channel.starts_with("https://www.youtube.com/channel/") {
return Err(invalid(
"Channel mappings require a canonical UC channel ID",
));
}
let (original, mut document) = read_document(path)?;
// Validate every category before touching the document or creating directories.
let mut config = parse_categories(&document)?;
let mut mappings = parse_channels(&document)?;
// Validate the whole file before changing it or creating directories.
parse_categories(&document, &mappings)?;
match mappings.get(&handle) {
Some(existing) if existing != &channel => {
return Err(invalid(format!(
"Handle {handle:?} already maps to another channel"
)));
}
Some(_) => {}
None => {
if !document.contains_key("channels") {
document.insert("channels", Item::Table(Table::new()));
}
document["channels"]
.as_table_like_mut()
.expect("channel mappings were validated")
.insert(&handle, value(&channel));
mappings.insert(handle.clone(), channel.clone());
}
}
if !document.contains_key("categories") {
document.insert("categories", Item::Table(Table::new()));
}
@@ -63,12 +88,22 @@ pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Con
.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);
let mut found = false;
for entry in channels.iter_mut() {
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)? == channel {
// Replace old URLs/aliases with the current handle, preserving entry comments.
if entry.as_str() != Some(&handle) {
let decor = entry.decor().clone();
*entry = handle.as_str().into();
*entry.decor_mut() = decor;
}
found = true;
}
}
if !found {
channels.push(handle);
}
let config = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(config)
}
@@ -130,6 +165,54 @@ fn normalize_channel(input: &str) -> io::Result<String> {
Ok(format!("https://www.youtube.com/channel/{id}"))
}
fn normalize_handle(input: &str) -> io::Result<String> {
let mut address = normalize_channel(input)?;
let prefix = "https://www.youtube.com/";
if !address[prefix.len()..].starts_with('@') {
return Err(invalid("Expected a YouTube @handle"));
}
address.drain(..prefix.len());
Ok(address)
}
fn parse_channels(document: &DocumentMut) -> io::Result<BTreeMap<String, String>> {
let mut mappings = BTreeMap::new();
let Some(item) = document.get("channels") else {
return Ok(mappings);
};
let channels = item
.as_table_like()
.ok_or_else(|| invalid("channels must be a TOML table"))?;
for (handle, address) in channels.iter() {
let handle = normalize_handle(handle)?;
let address = normalize_channel(
address
.as_str()
.ok_or_else(|| invalid("Channel mappings must be strings"))?,
)?;
if !address.starts_with("https://www.youtube.com/channel/") {
return Err(invalid(
"Channel mappings require a canonical UC channel ID",
));
}
if mappings.insert(handle, address).is_some() {
return Err(invalid("Duplicate channel handle after normalization"));
}
}
Ok(mappings)
}
fn resolve_channel(input: &str, mappings: &BTreeMap<String, String>) -> io::Result<String> {
let address = normalize_channel(input)?;
match address
.strip_prefix("https://www.youtube.com/")
.and_then(|handle| mappings.get(handle))
{
Some(mapped) => Ok(mapped.clone()),
None => Ok(address),
}
}
fn read_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
@@ -150,7 +233,10 @@ fn read_document(path: &Path) -> io::Result<(Option<Vec<u8>>, DocumentMut)> {
Ok((bytes, document))
}
fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
fn parse_categories(
document: &DocumentMut,
mappings: &BTreeMap<String, String>,
) -> io::Result<Config> {
let mut config = Config::default();
let Some(item) = document.get("categories") else {
return Ok(config);
@@ -165,10 +251,11 @@ fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
.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(
let channel = resolve_channel(
channel
.as_str()
.ok_or_else(|| invalid("Channels must be strings"))?,
mappings,
)?;
if !normalized.contains(&channel) {
normalized.push(channel);
@@ -278,22 +365,22 @@ mod tests {
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
let directory = TestDirectory::new();
let path = directory.0.join("nested/config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
assert!(load(&path).unwrap().categories.is_empty());
assert!(!path.parent().unwrap().exists());
let config = add_channel(&path, " Music ", "@example").unwrap();
let config = add_channel(&path, " Music ", "@example", id).unwrap();
assert_eq!(
config.categories["Music"],
["https://www.youtube.com/@example"]
[format!("https://www.youtube.com/channel/{id}")]
);
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();
let config = add_channel(&path, "Music", "https://youtube.com/@example", id).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();
let config = add_channel(&path, "Music", "@another", id).unwrap();
assert_eq!(
config.categories["Music"][1],
format!("https://www.youtube.com/channel/{id}")
@@ -318,20 +405,66 @@ mod tests {
"[categories]\nMusic = [3]\n",
"[categories]\nMusic = ['https://example.com/@example']\n",
"[categories]\nMusic = []\n' Music ' = []\n",
"channels = 3\n",
"[channels]\n'@example' = 3\n",
"[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
"[channels]\n'@example' = '@another'\n",
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
] {
fs::write(&path, malformed).unwrap();
assert!(load(&path).is_err());
assert!(add_channel(&path, "Music", "@example").is_err());
assert!(add_channel(&path, "Music", "@example", id).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!(
add_channel(
&path,
"Music",
"https://www.youtube.com/@example/videos",
id
)
.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 handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let manual = format!(
"[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n"
);
fs::write(&path, manual).unwrap();
let config = load(&path).unwrap();
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
assert_eq!(
config.categories["Direct"],
["https://www.youtube.com/@RustVideos"]
);
let config = add_channel(&path, "Japanese", "@jawed", id).unwrap();
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
let saved = fs::read_to_string(&path).unwrap();
let document = saved.parse::<DocumentMut>().unwrap();
let entries = document["categories"]["Japanese"].as_array().unwrap();
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str()));
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
assert!(saved.contains("# keep this entry comment"));
add_channel(&path, "Japanese", "@jawed", id).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
assert!(add_channel(&path, "Japanese", "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
}
#[test]
fn paths_and_channel_addresses_are_strict() {
let home = Some(OsString::from("/home/test"));