diff --git a/README.md b/README.md index 9e41550..9506530 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Each selection opens an independent player instance without replacing existing p | `d` | Open video info; press again for the full description, again to return to info | | Esc | Close the preview | | `c` | Browse saved categories | +| `u` | Browse an individual saved channel | | `s` | Edit the selected channel's tags | | `S` | Edit tags for all saved channels in the current category | | Tab / Shift-Tab | Switch between visible views | @@ -73,11 +74,11 @@ The full-description view scrolls with ↓ / ↑, Ctrl-J / Ctrl-K, or PageDown / ### Text input -These shortcuts work in search, category-filter, and new-tag fields: +These shortcuts work in search, category/channel-filter, and new-tag fields: | Key | Action | | --- | --- | -| Enter | Submit the search, browse the selected category, or stage the new tag | +| Enter | Submit the search, browse the selected category/channel, or stage the new tag | | Esc | Leave insert mode or abandon the new-tag name | | Backspace | Delete one character | | Option/Alt-Backspace or Ctrl-W | Delete the previous whitespace-delimited word | @@ -91,6 +92,14 @@ If the terminal intercepts modified Backspace rather than transmitting it, use C - **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses; Esc returns to normal mode without clearing the filter. - Reopening the browser clears its filter. No matches makes browsing and pinning a no-op. +### Saved channels (`u`) + +- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter opens the selected channel's videos; Esc closes. +- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses; Esc returns to normal mode without clearing the filter. +- The list includes registered channels, including untagged ones, and channels saved only in categories. Aliases of the same channel appear once, using a mapped handle when available; otherwise the saved channel identity is shown. +- Choosing a channel opens one temporary channel tab. Choosing another replaces it without replacing the temporary category tab. Revisiting a channel restores its cached videos, selection, and pagination. +- Channel selection is single-choice and does not pin channels or change subscriptions. `s` still edits the selected video's channel tags; bulk `S` remains category-only. + ### Channel tags (`s`, or `S` for bulk editing) - `j` / `k` or arrow keys navigate; Space toggles a tag. @@ -134,7 +143,7 @@ Bulk editing refuses to clear all tags when a target has no `[channels]` registr ## Loading and previews -Search loads 20 results per page; Feed and category views request 20 uploads per channel per page. Moving near the loaded end in normal mode fetches more asynchronously, preserving selection and existing rows. Insert mode and open popups pause automatic prefetch. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again. +Search loads 20 results per page; Feed and category views request 20 uploads per channel per page. Individual channel views request only that channel's uploads, using the same pagination. Moving near the loaded end in normal mode fetches more asynchronously, preserving selection and existing rows. Insert mode and open popups pause automatic prefetch. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again. Feed views merge newest-first using available upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted. Publication labels use `YYYY-MM-DD`; `~` marks an estimated UTC date, and `Unknown` means unavailable metadata. diff --git a/src/config.rs b/src/config.rs index 362c137..2dd02be 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,23 @@ impl Config { channels.dedup(); channels.into_iter().cloned().collect() } + + pub fn saved_channels(&self) -> Vec<(String, String)> { + let mut labels = BTreeMap::new(); + // Mappings are sorted by handle, so aliases use the first readable label. + for (handle, identity) in &self.channels { + labels.entry(identity).or_insert(handle); + } + for identity in self.categories.values().flatten() { + labels.entry(identity).or_insert(identity); + } + let mut channels: Vec<_> = labels + .into_iter() + .map(|(identity, label)| (label.clone(), identity.clone())) + .collect(); + channels.sort_unstable(); + channels + } } pub fn config_path() -> io::Result { @@ -1097,6 +1114,72 @@ mod tests { ); } + #[test] + fn saved_channels_use_readable_aliases_and_include_every_subscription_once() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let mapped_id = "UC4QobU6STFB0P71PMvOGN5A"; + let untagged_id = "UCabcdefghijklmnopqrstuv"; + let legacy_id = "UCzyxwvutsrqponmlkjihgfe"; + let mapped = format!("https://www.youtube.com/channel/{mapped_id}"); + let untagged = format!("https://www.youtube.com/channel/{untagged_id}"); + let legacy = format!("https://www.youtube.com/channel/{legacy_id}"); + fs::write( + &path, + format!( + "[channels]\n'@zAlias' = '{mapped_id}'\n'https://youtube.com/@aAlias' = '{mapped}'\n'@untagged' = '{untagged_id}'\n[categories]\nFirst = ['@zAlias', '{mapped_id}', '@manual', '{legacy_id}']\nSecond = ['@aAlias', 'https://youtube.com/@manual', '{legacy}']\n" + ), + ) + .unwrap(); + let config = load(&path).unwrap(); + let expected = vec![ + ("@aAlias".to_owned(), mapped.clone()), + ("@untagged".to_owned(), untagged.clone()), + ( + "https://www.youtube.com/@manual".to_owned(), + "https://www.youtube.com/@manual".to_owned(), + ), + (legacy.clone(), legacy.clone()), + ]; + assert_eq!(config.saved_channels(), expected); + assert_eq!(load(&path).unwrap().saved_channels(), expected); + let mut identities: Vec<_> = config + .saved_channels() + .into_iter() + .map(|(_, identity)| identity) + .collect(); + identities.sort_unstable(); + assert_eq!(identities, config.subscriptions()); + assert!( + config + .categories + .values() + .flatten() + .all(|id| id != &untagged) + ); + assert_eq!(config.channels["@zAlias"], mapped); + } + + #[test] + fn saved_channels_support_empty_and_registry_only_configs() { + assert!(Config::default().saved_channels().is_empty()); + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; + fs::write( + &path, + format!("[channels]\n'@zAlias' = '{identity}'\n'@aAlias' = '{identity}'\n"), + ) + .unwrap(); + let config = load(&path).unwrap(); + assert!(config.categories.is_empty()); + assert_eq!( + config.saved_channels(), + vec![("@aAlias".to_owned(), identity.to_owned())] + ); + assert_eq!(config.subscriptions(), [identity]); + } + #[test] fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() { let directory = TestDirectory::new(); diff --git a/src/main.rs b/src/main.rs index 79906c0..7efa961 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,11 @@ //! Browsing a pinned category focuses its existing tab and removes the temporary tab. //! Top-level `pinned_categories = ["Favorites"]` persists pins; missing category names are ignored. //! Tab/Shift-Tab cycle only visible tabs; hidden views retain cached rows and selection. +//! Normal-mode u opens a searchable saved-channel selector with the category browser's modal controls. +//! Choices include untagged registrations and category-only channels, deduplicated with readable handle labels. +//! One temporary channel tab is independent of the temporary category tab; hidden channel views retain cached pages. +//! Channel views use the same pagination but extract only their stored channel identity; S stays category-only. +//! Config changes preserve/relabel matching identities and drop unsubscribed views, falling back to Search if active. //! r reloads config and refreshes the current view. //! Feed includes saved `[channels]` entries, even untagged, plus direct tag entries, without duplicates. //! Untagging is not unsubscribing: remove mappings and tag references from the file to unsubscribe. @@ -103,7 +108,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 --new-instance --url followed by the canonical YouTube video URL and handles extraction, decoding, and playback itself; each selection opens an independent player instance without replacing existing playback.\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 opens video info (again for full description); 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.\nVIDEO INFO: d opens thumbnail and details; d again expands the full description inside the same popup; another d returns to info. Esc closes.\nThe popup uses up to 70% width and 60% height, capped at 96 by 20 cells.\nFull descriptions have one-column left/right padding and an empty line above the bold title and dimmed author/publication row. Title, metadata, and description scroll together as one document; paragraphs are preserved. Up/Down or Ctrl-J/K scroll lines and PgUp/PgDn scroll pages. Plain j/k selects videos in either mode.\nThumbnails have one column of left padding and two columns before the text. Image space is reserved before loading so metadata does not shift when decoding finishes.\nThe title is bold, only author/publication metadata is dimmed, and readable excerpts end with an ellipsis when clipped.\nImages fit proportionally on resize. Terminal graphics are detected at startup, falling back to colored half-blocks.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\nSwitching info/description retains in-flight work and cached content. Full descriptions have a session LRU of 64; thumbnails retain eight inactive images plus the selected image.\ni, c, s, S, view changes, and refresh dismiss the preview; successful refresh clears its caches. Typing d or t in INSERT 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 opens a new Terebi player, leaving existing playback untouched. 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 --new-instance --url followed by the canonical YouTube video URL and handles extraction, decoding, and playback itself; each selection opens an independent player instance without replacing existing playback.\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/channel filters, 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 opens video info (again for full description); c browses saved categories; u browses saved channels; 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.\nCHANNEL BROWSER (u): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter opens only the selected channel's videos; Esc closes. INSERT uses case-insensitive filtering, arrows or Ctrl-J/K to move, Enter to browse, and Esc to return to NORMAL. Empty matches do nothing.\nSaved choices include registered channels (even untagged) and category-only subscriptions, deduplicated by resolved identity; mapped handles label aliases. Channel selection never changes subscriptions or pins.\nOne temporary channel tab replaces the previous channel choice independently of the temporary category tab. Revisiting restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nReloading config relabels stable identities and removes unsubscribed channel views; removing the active channel falls back to Search.\nHidden category and channel 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.\nVIDEO INFO: d opens thumbnail and details; d again expands the full description inside the same popup; another d returns to info. Esc closes.\nThe popup uses up to 70% width and 60% height, capped at 96 by 20 cells.\nFull descriptions have one-column left/right padding and an empty line above the bold title and dimmed author/publication row. Title, metadata, and description scroll together as one document; paragraphs are preserved. Up/Down or Ctrl-J/K scroll lines and PgUp/PgDn scroll pages. Plain j/k selects videos in either mode.\nThumbnails have one column of left padding and two columns before the text. Image space is reserved before loading so metadata does not shift when decoding finishes.\nThe title is bold, only author/publication metadata is dimmed, and readable excerpts end with an ellipsis when clipped.\nImages fit proportionally on resize. Terminal graphics are detected at startup, falling back to colored half-blocks.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\nSwitching info/description retains in-flight work and cached content. Full descriptions have a session LRU of 64; thumbnails retain eight inactive images plus the selected image.\ni, c, s, S, view changes, and refresh dismiss the preview; successful refresh clears its caches. Typing d or t in INSERT 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 opens a new Terebi player, leaving existing playback untouched. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" )] struct Options { /// Terebi CLI executable; accepts --new-instance --url followed by a YouTube video URL. @@ -132,7 +137,27 @@ enum Mode { Normal, } +#[derive(Clone, Debug, PartialEq, Eq)] +enum ViewSource { + Search, + Feed, + Category(String), + Channel(String), +} + +impl ViewSource { + fn channels<'a>(&'a self, config: &'a config::Config, feed: &'a [String]) -> &'a [String] { + match self { + Self::Search => &[], + Self::Feed => feed, + Self::Category(name) => &config.categories[name], + Self::Channel(identity) => std::slice::from_ref(identity), + } + } +} + struct View { + source: ViewSource, name: String, title: String, videos: Vec