From e2fffa3afaa61d9025f4e95b7a685de4db70d15a Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 14:14:57 +0200 Subject: [PATCH] feat: edit category memberships for all channels in a category --- src/config.rs | 453 ++++++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 190 ++++++++++++++++++--- 2 files changed, 571 insertions(+), 72 deletions(-) diff --git a/src/config.rs b/src/config.rs index 6042679..362c137 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, ffi::OsString, fs::{self, OpenOptions}, io::{self, Write}, @@ -66,15 +66,7 @@ pub fn set_channel_tags( handle: &str, channel: &str, ) -> io::Result { - let mut normalized_tags = BTreeMap::new(); - for (name, checked) in tags { - let name = category_name(name)?; - if normalized_tags.insert(name, *checked).is_some() { - return Err(invalid(format!( - "Duplicate category name after trimming: {name:?}" - ))); - } - } + let normalized_tags = normalize_tags(tags)?; let handle = normalize_handle(handle)?; let channel = normalize_channel(channel)?; if !channel.starts_with("https://www.youtube.com/channel/") { @@ -144,46 +136,7 @@ pub fn set_channel_tags( channels.push(handle.as_str()); } } else { - for index in (0..channels.len()).rev() { - if resolve_channel( - channels - .get(index) - .unwrap() - .as_str() - .expect("channel was validated"), - &mappings, - )? != channel - { - continue; - } - let removed = channels.remove(index); - // Array comments can be attached to the next entry, not their own line. - let prefix = removed - .decor() - .prefix() - .and_then(|raw| raw.as_str()) - .unwrap_or(""); - let suffix = removed - .decor() - .suffix() - .and_then(|raw| raw.as_str()) - .unwrap_or(""); - if prefix.contains('#') || suffix.contains('#') { - if let Some(next) = channels.get_mut(index) { - let next_prefix = next - .decor() - .prefix() - .and_then(|raw| raw.as_str()) - .unwrap_or(""); - let preserved = format!("{prefix}{suffix}{next_prefix}"); - next.decor_mut().set_prefix(preserved); - } else { - let trailing = channels.trailing().as_str().unwrap_or(""); - let preserved = format!("{prefix}{suffix}{trailing}"); - channels.set_trailing(preserved); - } - } - } + remove_category_channels(channels, &mappings, |identity| identity == channel)?; } } let categories = parse_categories(&document, &mappings)?; @@ -195,6 +148,176 @@ pub fn set_channel_tags( }) } +pub fn set_category_tags( + path: &Path, + source: &str, + expected_channels: &[String], + tags: &BTreeMap, +) -> io::Result { + let source = category_name(source)?; + let tags = normalize_tags(tags)?; + let (original, mut document) = read_document(path)?; + let mappings = parse_channels(&document)?; + let parsed_categories = parse_categories(&document, &mappings)?; + let pinned_categories = parse_pins(&document)?; + let targets = parsed_categories + .get(source) + .ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?; + if targets.is_empty() { + return Err(invalid("Cannot edit tags for an empty source category")); + } + let expected: BTreeSet<_> = expected_channels.iter().map(String::as_str).collect(); + if targets.len() != expected.len() + || targets + .iter() + .any(|channel| !expected.contains(channel.as_str())) + { + return Err(invalid( + "Source category changed since opening the editor; please reopen it", + )); + } + if !tags.values().any(|checked| *checked) + && targets + .iter() + .any(|channel| !mappings.values().any(|mapped| mapped == channel)) + { + return Err(invalid( + "Cannot clear every category: a source channel is not registered in [channels]", + )); + } + + let categories = document["categories"] + .as_table_like_mut() + .expect("source category was validated"); + let source_entries = categories + .iter() + .find(|(name, _)| name.trim() == source) + .and_then(|(_, item)| item.as_array()) + .expect("source category was validated"); + let mut additions = BTreeMap::new(); + for entry in source_entries.iter() { + let raw = entry.as_str().expect("channel was validated").trim(); + let identity = resolve_channel(raw, &mappings)?; + let readable = raw + .strip_prefix("https://www.youtube.com/") + .or_else(|| raw.strip_prefix("https://youtube.com/")) + .filter(|address| address.starts_with('@')) + .unwrap_or(raw); + let addition = additions + .entry(identity) + .or_insert_with(|| readable.to_owned()); + if readable.starts_with('@') && !addition.starts_with('@') { + addition.clear(); + addition.push_str(readable); + } + } + for (handle, identity) in &mappings { + if let Some(addition) = additions.get_mut(identity) + && !addition.starts_with('@') + { + addition.clear(); + addition.push_str(handle); + } + } + // The checked set is exact, including categories added by another editor. + for (name, item) in categories.iter_mut() { + let entries = item.as_array_mut().expect("category was validated"); + if tags.get(name.trim()).copied().unwrap_or(false) { + let present: BTreeSet<_> = entries + .iter() + .map(|entry| { + resolve_channel(entry.as_str().expect("channel was validated"), &mappings) + }) + .collect::>()?; + for (identity, readable) in &additions { + if !present.contains(identity) { + entries.push(readable.as_str()); + } + } + } else { + remove_category_channels(entries, &mappings, |identity| { + additions.contains_key(identity) + })?; + } + } + for (name, checked) in tags { + if checked && !parsed_categories.contains_key(name) { + let mut entries = Array::new(); + for readable in additions.values() { + entries.push(readable.as_str()); + } + categories.insert(name, value(entries)); + } + } + let categories = parse_categories(&document, &mappings)?; + save(path, original.as_deref(), document.to_string().as_bytes())?; + Ok(Config { + channels: mappings, + categories, + pinned_categories, + }) +} + +fn normalize_tags(tags: &BTreeMap) -> io::Result> { + let mut normalized = BTreeMap::new(); + for (name, checked) in tags { + let name = category_name(name)?; + if normalized.insert(name, *checked).is_some() { + return Err(invalid(format!( + "Duplicate category name after trimming: {name:?}" + ))); + } + } + Ok(normalized) +} + +fn remove_category_channels( + channels: &mut Array, + mappings: &BTreeMap, + remove: impl Fn(&str) -> bool, +) -> io::Result<()> { + for index in (0..channels.len()).rev() { + if !remove(&resolve_channel( + channels + .get(index) + .unwrap() + .as_str() + .expect("channel was validated"), + mappings, + )?) { + continue; + } + let removed = channels.remove(index); + // Array comments can be attached to the next entry, not their own line. + let prefix = removed + .decor() + .prefix() + .and_then(|raw| raw.as_str()) + .unwrap_or(""); + let suffix = removed + .decor() + .suffix() + .and_then(|raw| raw.as_str()) + .unwrap_or(""); + if prefix.contains('#') || suffix.contains('#') { + if let Some(next) = channels.get_mut(index) { + let next_prefix = next + .decor() + .prefix() + .and_then(|raw| raw.as_str()) + .unwrap_or(""); + let preserved = format!("{prefix}{suffix}{next_prefix}"); + next.decor_mut().set_prefix(preserved); + } else { + let trailing = channels.trailing().as_str().unwrap_or(""); + let preserved = format!("{prefix}{suffix}{trailing}"); + channels.set_trailing(preserved); + } + } + } + Ok(()) +} + pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result { let name = category_name(name)?; let (original, mut document) = read_document(path)?; @@ -536,6 +659,236 @@ mod tests { } } + #[test] + fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let first = "UC4QobU6STFB0P71PMvOGN5A"; + let second = "UCabcdefghijklmnopqrstuv"; + let direct = "UC0123456789012345678901"; + let first_url = format!("https://www.youtube.com/channel/{first}"); + let second_url = format!("https://www.youtube.com/channel/{second}"); + let direct_url = format!("https://www.youtube.com/channel/{direct}"); + let manual_url = "https://www.youtube.com/@manual".to_owned(); + let original = format!( + "# manual preferences\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n" + ); + fs::write(&path, &original).unwrap(); + let opened = load(&path).unwrap(); + let before = original.parse::().unwrap(); + // This category was not present in the dialog's tags. + fs::write( + &path, + format!("{original}Late = ['@alias', '@unrelated'] # late note\n"), + ) + .unwrap(); + let tags = BTreeMap::from([ + ("Source".to_owned(), false), + ("Keep".to_owned(), true), + ("Drop".to_owned(), false), + (" New ".to_owned(), true), + ]); + let saved = + set_category_tags(&path, " Source ", &opened.categories["Source"], &tags).unwrap(); + assert!(saved.categories["Source"].is_empty()); + for name in ["Drop", "Late", "Untouched"] { + assert_eq!( + saved.categories[name], + ["https://www.youtube.com/@unrelated"] + ); + } + assert_eq!( + saved.categories["Keep"], + [ + first_url.clone(), + "https://www.youtube.com/@unrelated".to_owned(), + manual_url.clone(), + direct_url.clone(), + second_url.clone(), + ] + ); + assert_eq!( + saved.categories["New"], + [manual_url, direct_url, first_url, second_url] + ); + assert_eq!(saved.channels, opened.channels); + assert_eq!(saved.pinned_categories, ["Source"]); + let content = fs::read_to_string(&path).unwrap(); + let document = content.parse::().unwrap(); + assert_eq!( + document["channels"].to_string(), + before["channels"].to_string() + ); + assert_eq!( + document["pinned_categories"].to_string(), + before["pinned_categories"].to_string() + ); + assert_eq!(document["other"]["value"].as_integer(), Some(42)); + assert_eq!(document["player"].as_str(), Some("mpv")); + let new = document["categories"]["New"].as_array().unwrap(); + let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect(); + assert_eq!(raw, ["@manual", direct, "@alias", "@second"]); + let keep = document["categories"]["Keep"].as_array().unwrap(); + assert_eq!(keep.get(0).unwrap().as_str(), Some("@alias")); + assert_eq!(keep.get(1).unwrap().as_str(), Some("@unrelated")); + for comment in [ + "# manual preferences", + "# pin note", + "# alias mapping", + "# source channel", + "# direct channel", + "# source note", + "# keep note", + "# removed channel note", + "# unrelated note", + "# late note", + ] { + assert!(content.contains(comment), "lost {comment}"); + } + let reloaded = load(&path).unwrap(); + assert_eq!(reloaded.categories, saved.categories); + assert_eq!(reloaded.subscriptions(), saved.subscriptions()); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } + + #[test] + fn category_tags_compare_identity_sets_not_source_order_or_alias_spelling() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let url = format!("https://www.youtube.com/channel/{id}"); + let registry = format!("[channels]\n'@old' = '{id}'\n'@alias' = '{url}'\n"); + fs::write( + &path, + format!("{registry}[categories]\nSource = ['@old', '@manual']\n"), + ) + .unwrap(); + let expected = load(&path).unwrap().categories.remove("Source").unwrap(); + fs::write( + &path, + format!( + "{registry}[categories]\nSource = ['https://youtube.com/@manual', '{url}', '@alias'] # alias note\n" + ), + ) + .unwrap(); + let tags = BTreeMap::from([("Source".to_owned(), true), ("New".to_owned(), true)]); + let saved = set_category_tags(&path, "Source", &expected, &tags).unwrap(); + assert_eq!( + saved.categories["Source"], + ["https://www.youtube.com/@manual".to_owned(), url.clone()] + ); + assert_eq!( + saved.categories["New"], + ["https://www.youtube.com/@manual".to_owned(), url] + ); + let content = fs::read_to_string(&path).unwrap(); + let document = content.parse::().unwrap(); + let source = document["categories"]["Source"].as_array().unwrap(); + assert_eq!(source.len(), 3); + assert_eq!(source.get(2).unwrap().as_str(), Some("@alias")); + let new = document["categories"]["New"].as_array().unwrap(); + assert_eq!(new.get(1).unwrap().as_str(), Some("@alias")); + assert!(content.contains("# alias note")); + set_category_tags(&path, "Source", &expected, &tags).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + } + + #[test] + fn category_tags_reject_stale_sources_invalid_files_and_tags_without_writing() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let tags = BTreeMap::from([("New".to_owned(), true)]); + let expected = vec!["https://www.youtube.com/@manual".to_owned()]; + for contents in [ + "[categories]\nOther = ['@manual']\n", + "[categories]\nSource = []\n", + "[categories]\nSource = ['@different']\n", + "[categories]\nSource = ['@manual', '@different']\n", + "[channels]\n'@manual' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nSource = ['@manual']\n", + "[categories\n", + "categories = true\n", + "[categories]\nSource = ['@manual']\nInvalid = [7]\n", + "[categories]\nSource = ['@manual']\n' Source ' = []\n", + "channels = true\n[categories]\nSource = ['@manual']\n", + "[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n", + "pinned_categories = [7]\n[categories]\nSource = ['@manual']\n", + ] { + fs::write(&path, contents).unwrap(); + assert!(set_category_tags(&path, "Source", &expected, &tags).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), contents); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } + fs::write(&path, [0xff]).unwrap(); + assert!(set_category_tags(&path, "Source", &expected, &tags).is_err()); + assert_eq!(fs::read(&path).unwrap(), [0xff]); + let valid = "[categories]\nSource = ['@manual']\n"; + fs::write(&path, valid).unwrap(); + for invalid_tags in [ + BTreeMap::from([(" ".to_owned(), true)]), + BTreeMap::from([("bad\nname".to_owned(), true)]), + BTreeMap::from([("New".to_owned(), true), (" New ".to_owned(), false)]), + ] { + assert!(set_category_tags(&path, "Source", &expected, &invalid_tags).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), valid); + } + assert!(set_category_tags(&path, "bad\nname", &expected, &tags).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), valid); + let missing = directory.0.join("missing/config.toml"); + assert!(set_category_tags(&missing, "Source", &expected, &tags).is_err()); + assert!(!missing.parent().unwrap().exists()); + } + + #[test] + fn category_tags_only_clear_all_tags_when_every_target_remains_registered() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let registry = format!( + "pinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n" + ); + for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] { + let original = format!("{registry}Source = [{source}]\n"); + fs::write(&path, &original).unwrap(); + let opened = load(&path).unwrap(); + for tags in [ + BTreeMap::new(), + BTreeMap::from([("Source".to_owned(), false)]), + ] { + let error = set_category_tags(&path, "Source", &opened.categories["Source"], &tags) + .unwrap_err(); + assert!(error.to_string().contains("not registered in [channels]")); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } + } + let original = format!( + "{registry}Source = ['@mapped', '{id}'] # source note\nOther = ['https://youtube.com/@mapped', '@unrelated']\n" + ); + fs::write(&path, &original).unwrap(); + let opened = load(&path).unwrap(); + let saved = set_category_tags( + &path, + "Source", + &opened.categories["Source"], + &BTreeMap::new(), + ) + .unwrap(); + assert!(saved.categories["Source"].is_empty()); + assert_eq!( + saved.categories["Other"], + ["https://www.youtube.com/@unrelated"] + ); + assert_eq!(saved.channels, opened.channels); + assert_eq!(saved.pinned_categories, ["Source"]); + assert_eq!( + saved.subscriptions(), + [ + "https://www.youtube.com/@unrelated", + "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A", + ] + ); + assert!(fs::read_to_string(&path).unwrap().contains("# source note")); + } + #[test] fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() { let directory = TestDirectory::new(); diff --git a/src/main.rs b/src/main.rs index 7015414..d974bb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,11 @@ //! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing. //! Normal-mode s edits memberships: j/k moves, Space toggles, Enter commits, Esc cancels. //! n prompts for a new tag; Enter stages it checked, Esc returns. Only list Enter writes the file. +//! Normal-mode S in a category view edits every saved channel, even with no videos loaded. +//! Shared tags start checked; Enter atomically replaces every target's tags with the checked set. +//! Unchecked tags lose only target channels; other creators, registrations, pins, and empty categories remain. +//! n stages a new shared tag; Esc cancels the whole edit. Changed source membership requires reopening. +//! Clearing all tags is refused for category-only channels, avoiding accidental unsubscription. //! Search and Feed are permanent tabs. Normal-mode c opens the saved-category browser. //! The category browser starts NORMAL: j/k or arrows move, i filters, Enter browses, p pins, Esc closes. //! INSERT filters live with case-insensitive substring matching; arrows or Ctrl-J/K move, Enter browses. @@ -94,7 +99,7 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube and open selected videos in Terebi", - after_help = "Requires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --url followed by the canonical YouTube video URL and handles extraction, decoding, and playback itself; Tubular no longer pipes media to a player.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search, category filter, and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; d toggles description; t toggles thumbnail; c browses saved categories; s edits channel tags; q quits.\nSearch and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter browses; p immediately saves pin/unpin; Esc closes. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses the selected match; Esc returns to NORMAL with the filter retained. In NORMAL, j/k and pinning operate on the filtered list. No matches makes Enter/p a no-op. Reopening the selector clears the filter.\nHidden category views retain loaded rows, selection, and pagination for this session. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\npinned_categories is a top-level array of category names; pins referencing removed categories are ignored.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, s, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nSelecting another video reuses Terebi's window. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" + after_help = "Requires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --url followed by the canonical YouTube video URL and handles extraction, decoding, and playback itself; Tubular no longer pipes media to a player.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search, category filter, and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; d toggles description; t toggles thumbnail; c browses saved categories; s edits channel tags; S edits all channels in the current category; q quits.\nSearch and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter browses; p immediately saves pin/unpin; Esc closes. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses the selected match; Esc returns to NORMAL with the filter retained. In NORMAL, j/k and pinning operate on the filtered list. No matches makes Enter/p a no-op. Reopening the selector clears the filter.\nHidden category views retain loaded rows, selection, and pagination for this session. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\npinned_categories is a top-level array of category names; pins referencing removed categories are ignored.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving a single channel requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nBULK TAGS (S, category views only): edits all saved channels in the current category, not just channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Enter atomically applies the exact checked tag set to every target, removing targets from all unchecked categories while leaving other channels untouched. Empty categories, registrations, pins, and comments remain. n stages a new category for all targets; Esc cancels without saving. If source channels changed while editing, close and reopen. Clearing all tags is refused if any target lacks a [channels] registration, so category-only subscriptions are not accidentally lost.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, s, S, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nSelecting another video reuses Terebi's window. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" )] struct Options { /// Terebi CLI executable; accepts --url followed by a YouTube video URL. @@ -220,9 +225,13 @@ impl View { } } +enum CategoryTarget { + Channel { handle: String, channel: String }, + Category { name: String, channels: Vec }, +} + struct CategoryDialog { - channel: String, - handle: String, + target: CategoryTarget, label: String, tags: BTreeMap, selection: ListState, @@ -613,7 +622,7 @@ impl App { .is_empty() { return format!( - "{} has no channels. Press c on a search result to add one.", + "{} has no channels. Press s on a search result to add one.", view.name ); } @@ -787,8 +796,7 @@ impl App { self.description_open = false; self.description_job = None; self.category_dialog = Some(CategoryDialog { - channel, - handle, + target: CategoryTarget::Channel { handle, channel }, label, tags, selection, @@ -797,6 +805,51 @@ impl App { }); } + fn open_bulk_category_picker(&mut self) { + if self.view_index < 2 { + self.status = "Browse a category first to edit all its channels.".into(); + return; + } + let name = self.views[self.view_index].name.clone(); + let config = match config::load(&self.config_path) { + Ok(config) => config, + Err(error) => { + self.status = error.to_string(); + return; + } + }; + let channels = config.categories.get(&name).cloned().unwrap_or_default(); + self.apply_config(config); + if channels.is_empty() { + self.status = format!("{name} has no saved channels to edit."); + return; + } + let tags = self + .config + .categories + .iter() + .map(|(tag, members)| { + ( + tag.clone(), + channels.iter().all(|channel| members.contains(channel)), + ) + }) + .collect(); + let mut selection = ListState::default(); + selection.select(self.config.categories.keys().position(|tag| tag == &name)); + self.close_thumbnail(); + self.description_open = false; + self.description_job = None; + self.category_dialog = Some(CategoryDialog { + label: format!(" {name} · {} channels ", channels.len()), + target: CategoryTarget::Category { name, channels }, + tags, + selection, + name: None, + error: None, + }); + } + fn category_key(&mut self, key: KeyEvent) { let mut dialog = self .category_dialog @@ -871,16 +924,30 @@ impl App { )); } KeyCode::Enter => { - match config::set_channel_tags( - &self.config_path, - &dialog.tags, - &dialog.handle, - &dialog.channel, - ) { + let result = match &dialog.target { + CategoryTarget::Channel { handle, channel } => config::set_channel_tags( + &self.config_path, + &dialog.tags, + handle, + channel, + ), + CategoryTarget::Category { name, channels } => config::set_category_tags( + &self.config_path, + name, + channels, + &dialog.tags, + ), + }; + match result { Ok(config) => { self.apply_config(config); self.switch_view(self.view_index); - self.status = "Saved channel tags.".into(); + self.status = match &dialog.target { + CategoryTarget::Channel { .. } => "Saved channel tags.".into(), + CategoryTarget::Category { channels, .. } => { + format!("Saved tags for {} channels.", channels.len()) + } + }; return; } Err(error) => dialog.error = Some(error.to_string()), @@ -1090,6 +1157,7 @@ impl App { } KeyCode::Char('c') => self.open_category_browser(), KeyCode::Char('s') => self.open_category_picker(), + KeyCode::Char('S') => self.open_bulk_category_picker(), KeyCode::Char('r') => self.refresh(), KeyCode::Tab => self.cycle_view(false), KeyCode::BackTab => self.cycle_view(true), @@ -1264,7 +1332,7 @@ impl App { "NORMAL · j/k video · t/Esc close · d description · i insert · q quit" } Mode::Normal => { - "NORMAL · j/k · Enter play · i search · d info · t image · c browse · s tags · Tab views · r refresh · q quit" + "NORMAL · j/k · Enter play · i search · c browse · s channel tags · S bulk tags · d info · t image · Tab views · r refresh · q quit" } }; frame.render_widget(Paragraph::new(help_text).dim(), help); @@ -1512,11 +1580,16 @@ impl App { return; }; let area = frame.area(); + let bulk = matches!(&dialog.target, CategoryTarget::Category { .. }); let width = area.width.saturating_sub(4).min(60); let height = area.height.saturating_sub(4).min(if dialog.name.is_some() { 7 } else { - dialog.tags.len().saturating_add(5).clamp(7, 16) as u16 + dialog + .tags + .len() + .saturating_add(if bulk { 7 } else { 5 }) + .clamp(7, if bulk { 18 } else { 16 }) as u16 }); let popup = Rect::new( area.x + (area.width - width) / 2, @@ -1528,18 +1601,26 @@ impl App { let block = Block::bordered() .title(if dialog.name.is_some() { " New tag " + } else if bulk { + " Bulk tags " } else { " Tags " }) .title_bottom(dialog.label.as_str()) .border_style(Style::new().fg(Color::Cyan)); - let [body, error, help] = Layout::vertical([ + let [body, warning, error, help] = Layout::vertical([ Constraint::Min(1), + Constraint::Length(if bulk && dialog.name.is_none() { 2 } else { 0 }), Constraint::Length(if dialog.error.is_some() { 2 } else { 0 }), Constraint::Length(1), ]) .areas(block.inner(popup)); frame.render_widget(block, popup); + if bulk && dialog.name.is_none() { + frame.render_widget(Paragraph::new( + "Saving replaces all memberships. Only tags shared by every channel start checked." + ).wrap(Wrap { trim: true }).dim(), warning); + } if let Some(name) = &dialog.name { let text = Line::raw(name.as_str()); let scroll = text @@ -1699,6 +1780,64 @@ mod tests { ) } + #[test] + fn bulk_picker_uses_all_saved_channels_and_commits_or_cancels_the_whole_group() { + let path = + std::env::temp_dir().join(format!("tubular-bulk-picker-{}.toml", std::process::id())); + let original = "pinned_categories = ['Source']\n[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@other' = 'UCBR8-60-B28hp2BmDPdntcQ'\n[categories]\nEmpty = []\nMixed = ['@a', '@other']\nShared = ['@a', '@b', '@other']\nSource = ['@a', '@b']\n"; + std::fs::write(&path, original).unwrap(); + let mut app = App::new( + Options::parse_from(["tubular"]), + path.clone(), + config::load(&path).unwrap(), + ); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + assert!(app.category_dialog.is_none()); + app.view_index = app + .views + .iter() + .position(|view| view.name == "Source") + .unwrap(); + app.views[app.view_index].loaded = true; + app.views[1].loaded = true; + app.views[1].next_start = 41; + let subscriptions = app.feed_channels.clone(); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + let dialog = app.category_dialog.as_ref().unwrap(); + assert!(dialog.tags["Shared"] && dialog.tags["Source"]); + assert!(!dialog.tags["Mixed"] && !dialog.tags["Empty"]); + assert!( + matches!(&dialog.target, CategoryTarget::Category { channels, .. } if channels.len() == 2) + ); + assert!(app.views[app.view_index].videos.is_empty()); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + for c in "Staged".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + assert!(!app.config.categories.contains_key("Staged")); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.is_none()); + let saved = config::load(&path).unwrap(); + assert!(saved.categories["Source"].is_empty()); + assert_eq!( + saved.categories["Mixed"], + ["https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ"] + ); + assert_eq!(saved.categories["Shared"].len(), 3); + assert_eq!(saved.pinned_categories, ["Source"]); + assert_eq!(app.feed_channels, subscriptions); + assert!(app.views[1].loaded); + assert_eq!(app.views[1].next_start, 41); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + assert!(app.category_dialog.is_none()); + std::fs::remove_file(path).unwrap(); + } + #[test] fn filtering_preserves_selected_categories_and_browses_the_filtered_row() { let mut app = app(&["tubular"]); @@ -2295,8 +2434,10 @@ mod tests { assert!(!app.can_prefetch()); app.thumbnail_open = false; app.category_dialog = Some(CategoryDialog { - channel: String::new(), - handle: String::new(), + target: CategoryTarget::Channel { + channel: String::new(), + handle: String::new(), + }, label: String::new(), tags: BTreeMap::new(), selection: ListState::default(), @@ -2356,8 +2497,10 @@ mod tests { assert_eq!(terminal.get_cursor_position().unwrap(), (x, 2).into()); } app.category_dialog = Some(CategoryDialog { - channel: String::new(), - handle: String::new(), + target: CategoryTarget::Channel { + channel: String::new(), + handle: String::new(), + }, label: String::new(), tags: BTreeMap::new(), selection: ListState::default(), @@ -2416,8 +2559,11 @@ mod tests { if tag_input { app.mode = Mode::Normal; app.category_dialog = Some(CategoryDialog { - channel: "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".into(), - handle: "@jawed".into(), + target: CategoryTarget::Channel { + channel: "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A" + .into(), + handle: "@jawed".into(), + }, label: "jawed".into(), tags: BTreeMap::new(), selection: ListState::default(),