diff --git a/README.md b/README.md index 111fb48..7858ab9 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | `W` | Browse watch later for the current category, or the combined collection elsewhere | | `f` | In watch later, cycle All → Unwatched → Watched | | `D` | In watch later, confirm removal of all videos matching the current list/filter | +| `v` | Save the selected video to saved videos (also works in previews) | +| `V` | Browse saved videos for the current category, or the combined collection elsewhere | +| `x` | Remove only the selected saved video (also works in previews) | | Tab / Shift-Tab | Switch between visible views | | `r` | Reload configuration and refresh the current view | | `?` | Open / close the scrollable keybinding reference | @@ -162,7 +165,7 @@ Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config. - **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates. -- Browsing an unpinned category, channel, or watch-later list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`. +- Browsing an unpinned category, channel, watch-later list, or saved-video list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`. - Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`. - Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session. @@ -174,7 +177,7 @@ Watch later is **one collection**, deduplicated by YouTube video ID. Category li Browse a category with `c`, then use `W` for its watch-later list. From Search, Feed, or a channel view, `W` opens the combined collection. Watch-later views use saved title, creator, and publication metadata without fetching video lists, so they also work offline. Their filters are independent and retained during the session; each pane's border title shows the active All, Unwatched, or Watched filter. -Saved video titles are **magenta** in all video tables; categorized channel names remain cyan. A `✓` marks watched videos, including outside watch-later views. The selected row retains its usual highlight. +Watch-later-only titles are **magenta** in all video tables; categorized channel names remain cyan. A `✓` marks watched videos, including outside watch-later views. The selected row retains its usual highlight. ### Watched status @@ -189,6 +192,7 @@ Terebi reports acknowledged playback positions. A video becomes watched when an Files are siblings of `config.toml`: - `watch-later.toml`: versioned saved-video metadata. +- `saved-videos.toml`: independent versioned saved-video metadata. - `watch-state.toml`: versioned global watched video IDs. - `playback-sessions/`: private per-launch progress streams and their expected video identities. @@ -196,6 +200,18 @@ State writes are atomic and owner-only. Invalid state files or detected external Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. They are retained because the GUI may still be writing; up to 4,096 sessions are monitored. If that limit is reached, close the corresponding players, remove their inactive session directories, and restart Tubular. Never remove an active player's reporting directory. +## Saved videos + +Saved videos is a separate collection from watch later, deduplicated by YouTube video ID. It uses the same offline title, creator, and publication metadata and follows **current channel category memberships**, not categories stored on individual videos. + +Browse a category with `c`, then use `V` for its saved-video list. From Search, Feed, or a channel view, `V` opens the combined collection. Switching between a category's saved videos and watch later with `V` / `W` retains that category. Both lists share the existing temporary-tab and back/forward history behavior. + +`x` removes **only the selected video** from saved videos, whether in a list or a preview. Removing it never changes watch-later membership or global watched status. There is no watched filter or bulk removal for saved videos. + +Saved titles are **green**, taking precedence over watch-later magenta when a video belongs to both collections. Removing it from saved videos restores magenta if it remains in watch later. Channel styling, selected-row highlighting, and watched markers are unchanged. + +The collection is persisted independently in `saved-videos.toml` beside `config.toml`, using the same atomic, owner-only writes and validation as watch later. Saving repeatedly does not add duplicates. Configure the actions with `video.saved.save`, `view.saved.browse`, and `video.saved.remove`; the live hints and `?` reference reflect overrides. + ## Configurable keybindings Print a complete default configuration, including every action and its default bindings: diff --git a/src/config.rs b/src/config.rs index 2d1a2ec..d920a59 100644 --- a/src/config.rs +++ b/src/config.rs @@ -670,7 +670,7 @@ mod tests { let directory = TestDirectory::new(); let path = directory.0.join("config.toml"); - let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\n\n[keys.selector]\npin = 'f2' # pin note\n"; + let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\nvideo.saved.remove = []\n\n[keys.selector]\npin = 'f2' # pin note\n"; let sections = [ ("keys", key_section), ("settings", "[settings]\npinned_categories = []\n"), diff --git a/src/keys.rs b/src/keys.rs index a79b925..c4b9e4f 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -109,6 +109,9 @@ actions! { WatchLaterBrowse, "view.watch_later.browse", "Browse this category's watch later, or the combined collection elsewhere", &[Context::Normal], ["W"]; WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal], ["f"]; WatchLaterRemove, "view.watch_later.remove", "Confirm removal of the current filtered watch-later subset", &[Context::Normal], ["D"]; + SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"]; + SavedVideoBrowse, "view.saved.browse", "Browse this category's saved videos, or the combined collection elsewhere", &[Context::Normal], ["V"]; + SavedVideoRemove, "video.saved.remove", "Remove the selected saved video (also in previews)", VIEW, ["x"]; SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"]; SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"]; SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"]; @@ -477,6 +480,23 @@ impl Bindings { ), ], ), + ( + "SAVED VIDEOS", + &[ + ( + &[SavedVideoSave], + "Save the selected video (also in previews)", + ), + ( + &[SavedVideoBrowse], + "Browse category saved videos; combined elsewhere", + ), + ( + &[SavedVideoRemove], + "Remove the selected video (also in previews)", + ), + ], + ), ( "FULL DESCRIPTION", &[ @@ -926,8 +946,8 @@ mod tests { #[test] fn aliases_replace_defaults_and_can_disable_actions() { - let bindings = configured("[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap(); - let quoted = configured("[keys]\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap(); + let bindings = configured("[keys.video.saved]\nremove = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap(); + let quoted = configured("[keys]\n'video.saved.remove' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap(); assert_eq!(quoted, bindings); assert_eq!( press( @@ -1047,6 +1067,47 @@ mod tests { ); } + #[test] + fn saved_video_overrides_disable_and_respect_contexts() { + let defaults = Bindings::default(); + for context in ALL { + for (key, action, enabled) in [ + ('v', Action::SavedVideoSave, VIEW.contains(context)), + ('V', Action::SavedVideoBrowse, *context == Context::Normal), + ('x', Action::SavedVideoRemove, VIEW.contains(context)), + ] { + assert_eq!( + press(&defaults, *context, KeyCode::Char(key), KeyModifiers::NONE), + enabled.then_some(action), + ); + } + } + let bindings = configured( + "[keys.video.saved]\nsave = 'alt+v'\nremove = []\n[keys.view.saved]\nbrowse = 'shift+x'\n", + ) + .unwrap(); + for context in ALL { + assert_eq!( + press(&bindings, *context, KeyCode::Char('v'), KeyModifiers::ALT), + VIEW.contains(context).then_some(Action::SavedVideoSave), + ); + assert_eq!( + press(&bindings, *context, KeyCode::Char('x'), KeyModifiers::SHIFT), + (*context == Context::Normal).then_some(Action::SavedVideoBrowse), + ); + for key in ['v', 'V', 'x'] { + assert_eq!( + press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE), + None, + ); + } + } + let collision = configured("[keys]\nview.saved.browse = 'v'") + .unwrap_err() + .to_string(); + assert!(collision.contains("view.saved.browse") && collision.contains("video.saved.save")); + } + #[test] fn invalid_entries_and_collisions_have_actionable_errors() { for (source, path) in [ diff --git a/src/main.rs b/src/main.rs index fc8a14e..c2670e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,15 +92,17 @@ //! `--player` overrides its CLI path; otherwise `terebi` is resolved through PATH. //! The CLI exits after handoff, not playback completion. Later playback errors appear in Terebi. //! Quitting cancels pending metadata/launch processes, not the separately running player. -//! w saves the selected video once; W browses the current category's watch-later list, or all saved videos. -//! Watch-later is one offline collection beside config.toml; current channel tags determine category lists. -//! Saved titles are magenta; ✓ marks globally watched videos after acknowledged eligible playback reaches 95%. +//! w saves to watch later; W browses its current category list, or the combined collection elsewhere. +//! v saves to saved videos; V browses its current category list, or the combined collection elsewhere. +//! x removes only the selected saved video, also from previews; watch later and watched state are unchanged. +//! Both collections store offline metadata beside config.toml; current channel tags determine category lists. +//! Saved titles are green before watch-later magenta; ✓ marks acknowledged eligible playback reaching 95%. //! In watch-later, f cycles All/Unwatched/Watched; D asks before removing the filtered subset (y confirms). mod config; mod keys; mod thumbnail; -mod watch_later; +mod video_state; mod youtube; use std::{ @@ -128,13 +130,14 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Cell, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap}, }; +use video_state::CollectionKind; use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[derive(Parser)] #[command( version, about = "Browse YouTube and open selected videos in Terebi", - after_help = "Key names below describe default bindings. [keys] overrides named actions with a string or array; omitted actions keep defaults and [] disables an action. Live hints and help reflect overrides.\n--default-config prints complete default TOML to stdout and exits without loading config or starting a terminal.\nRequires 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 plus --progress-file for an independent private reporting stream 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.\nNORMAL ? opens a scrollable keybinding reference over the current view or dialog. The bottom bar shows only essential actions. Scroll help with j/k, arrows, Ctrl-J/K or PageUp/PageDown; ? or Esc closes it. Question mark remains literal text in input fields.\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; U opens the selected video's channel; s edits channel tags; S edits all channels in the current category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens the current category's watch-later list, or the combined collection elsewhere. Lists follow current channel category memberships and use persisted metadata offline. In watch-later views f cycles All/Unwatched/Watched; D prompts to remove the current filtered subset from the shared collection, y confirms, n/Esc cancels, and Enter never confirms. Saved titles are magenta; a checkmark marks watched videos in every table. Eligible acknowledged progress reaching 95% of a known duration marks watched; seeking near the end counts, launcher exit does not. Separate watch-later.toml and watch-state.toml sit beside config.toml. Private playback-sessions survive Tubular exit for next-launch recovery.\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; Esc or - in NORMAL goes back through view history; + goes forward. Both restore cached rows, selection, and pagination, including replaced temporary tabs. New view navigation clears the forward branch. Removed views are skipped and empty history does nothing. Esc first closes popups or leaves INSERT without navigating; minus and plus type normally in input fields. 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.\nChannels and unpinned categories share one temporary browsing tab. Choosing either replaces the previous temporary view while pinned category tabs remain visible. Revisiting or navigating back restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nVIDEO CHANNEL (U / Shift-U): opens the selected video's creator in the same temporary channel tab, reusing cached rows and pagination without subscribing. A canonical channel ID or valid @handle is required; unavailable metadata leaves the current view unchanged. Saved mappings resolve handle-only rows. Unsaved channels do not appear in the saved selector unless you save them.\nReloading config relabels stable saved identities and removes explicitly unsubscribed channels; removing the active saved channel falls back to Search. Unsaved channels opened with U remain browseable for the session.\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:\n[settings]\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[settings].pinned_categories is an array of category names; pins referencing removed categories are ignored. Settings, channels, categories and keys tables can appear in any order. Add [settings] above pinned_categories in older configs; root-level pins are no longer accepted.\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.\nTAG FILTER (s/S): i edits a case-insensitive substring filter; arrows or Ctrl-J/K move through matches while typing. Enter or Esc returns to NORMAL without saving or clearing the filter. Hidden staged memberships are retained; no matches makes navigation/toggling a no-op. Reopening clears the filter.\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, clears the filter, selects the new tag, 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 = "Key names below describe default bindings. [keys] overrides named actions with a string or array; omitted actions keep defaults and [] disables an action. Live hints and help reflect overrides.\n--default-config prints complete default TOML to stdout and exits without loading config or starting a terminal.\nRequires 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 plus --progress-file for an independent private reporting stream 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.\nNORMAL ? opens a scrollable keybinding reference over the current view or dialog. The bottom bar shows only essential actions. Scroll help with j/k, arrows, Ctrl-J/K or PageUp/PageDown; ? or Esc closes it. Question mark remains literal text in input fields.\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; U opens the selected video's channel; s edits channel tags; S edits all channels in the current category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens the current category's watch-later list, or the combined collection elsewhere. Lists follow current channel category memberships and use persisted metadata offline. In watch-later views f cycles All/Unwatched/Watched; D prompts to remove the current filtered subset from the shared collection, y confirms, n/Esc cancels, and Enter never confirms. Watch-later titles are magenta; a checkmark marks watched videos in every table. Eligible acknowledged progress reaching 95% of a known duration marks watched; seeking near the end counts, launcher exit does not. Separate watch-later.toml and watch-state.toml sit beside config.toml. Private playback-sessions survive Tubular exit for next-launch recovery.\nSAVED VIDEOS: v saves the selected video once (also in previews); V opens the current category's saved-video list, including from a category watch-later list, or the combined collection elsewhere. W likewise retains a saved list's category. Lists derive from current channel memberships and use offline metadata in saved-videos.toml beside config.toml. x removes only the selected saved video in normal or preview mode, without changing watch later or watched state. Saved titles are green, taking precedence over watch-later magenta. Saved lists have no watched filter or bulk removal. Both collections share the temporary tab and cached back/forward history.\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; Esc or - in NORMAL goes back through view history; + goes forward. Both restore cached rows, selection, and pagination, including replaced temporary tabs. New view navigation clears the forward branch. Removed views are skipped and empty history does nothing. Esc first closes popups or leaves INSERT without navigating; minus and plus type normally in input fields. 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.\nChannels and unpinned categories share one temporary browsing tab. Choosing either replaces the previous temporary view while pinned category tabs remain visible. Revisiting or navigating back restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nVIDEO CHANNEL (U / Shift-U): opens the selected video's creator in the same temporary channel tab, reusing cached rows and pagination without subscribing. A canonical channel ID or valid @handle is required; unavailable metadata leaves the current view unchanged. Saved mappings resolve handle-only rows. Unsaved channels do not appear in the saved selector unless you save them.\nReloading config relabels stable saved identities and removes explicitly unsubscribed channels; removing the active saved channel falls back to Search. Unsaved channels opened with U remain browseable for the session.\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:\n[settings]\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[settings].pinned_categories is an array of category names; pins referencing removed categories are ignored. Settings, channels, categories and keys tables can appear in any order. Add [settings] above pinned_categories in older configs; root-level pins are no longer accepted.\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.\nTAG FILTER (s/S): i edits a case-insensitive substring filter; arrows or Ctrl-J/K move through matches while typing. Enter or Esc returns to NORMAL without saving or clearing the filter. Hidden staged memberships are retained; no matches makes navigation/toggling a no-op. Reopening clears the filter.\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, clears the filter, selects the new tag, 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; must support --new-instance, --url, and --progress-file. @@ -174,12 +177,13 @@ enum ViewSource { Category(String), Channel(String), WatchLater(Option), + SavedVideos(Option), } impl ViewSource { fn channels<'a>(&'a self, config: &'a config::Config, feed: &'a [String]) -> &'a [String] { match self { - Self::Search | Self::WatchLater(_) => &[], + Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) => &[], Self::Feed => feed, Self::Category(name) => &config.categories[name], Self::Channel(identity) => std::slice::from_ref(identity), @@ -451,6 +455,7 @@ struct KeyHints { tags_empty: String, help: String, watch_later: String, + saved_videos: String, } impl KeyHints { @@ -461,12 +466,14 @@ impl KeyHints { ( Context::Normal, format!( - "NORMAL · {}/{} select · {} play · {} save · {} watch later · {} search · {} help · {} quit", + "NORMAL · {}/{} select · {} play · {}/{} later · {}/{} saved · {} search · {} help · {} quit", key(Action::VideoNext), key(Action::VideoPrevious), key(Action::VideoPlay), key(Action::WatchLaterSave), key(Action::WatchLaterBrowse), + key(Action::SavedVideoSave), + key(Action::SavedVideoBrowse), key(Action::SearchEdit), key(Action::Help), key(Action::Quit) @@ -475,9 +482,11 @@ impl KeyHints { ( Context::Info, format!( - "INFO · {} description · {} save · {} close · {} help", + "INFO · {} description · {} later · {} save · {} unsave · {} close · {} help", key(Action::InfoDescription), key(Action::WatchLaterSave), + key(Action::SavedVideoSave), + key(Action::SavedVideoRemove), key(Action::InfoHide), key(Action::Help) ), @@ -485,11 +494,13 @@ impl KeyHints { ( Context::Description, format!( - "DESCRIPTION · {}/{} scroll · {} info · {} save · {} close · {} help", + "DESCRIPTION · {}/{} scroll · {} info · {} later · {} save · {} unsave · {} close · {} help", key(Action::DescriptionNext), key(Action::DescriptionPrevious), key(Action::InfoOverview), key(Action::WatchLaterSave), + key(Action::SavedVideoSave), + key(Action::SavedVideoRemove), key(Action::InfoHide), key(Action::Help) ), @@ -570,17 +581,21 @@ impl KeyHints { ], preview: [ format!( - "· {} description · {} save · {} close ", + "· {} description · {} later · {} save · {} unsave · {} close ", key(Action::InfoDescription), key(Action::WatchLaterSave), + key(Action::SavedVideoSave), + key(Action::SavedVideoRemove), key(Action::InfoHide) ), format!( - "· {}/{} scroll · {} info · {} save · {} close ", + "· {}/{} scroll · {} info · {} later · {} save · {} unsave · {} close ", key(Action::DescriptionNext), key(Action::DescriptionPrevious), key(Action::InfoOverview), key(Action::WatchLaterSave), + key(Action::SavedVideoSave), + key(Action::SavedVideoRemove), key(Action::InfoHide) ), ], @@ -636,6 +651,7 @@ impl KeyHints { key(Action::WatchLaterFilter), key(Action::WatchLaterRemove) ), + saved_videos: format!("· {} remove selected ", key(Action::SavedVideoRemove)), } } @@ -664,7 +680,7 @@ struct App { submitted_query: String, config_path: PathBuf, config: config::Config, - watch_later: watch_later::State, + video_state: video_state::State, watch_removal: Option, key_hints: KeyHints, feed_channels: Vec, @@ -705,7 +721,7 @@ impl App { options, config_path, config, - watch_later::State::open(&path).unwrap(), + video_state::State::open(&path).unwrap(), ) } @@ -713,7 +729,7 @@ impl App { options: Options, config_path: PathBuf, config: config::Config, - watch_later: watch_later::State, + video_state: video_state::State, ) -> Self { let query = options.query.clone().unwrap_or_default(); let feed_channels = config.subscriptions(); @@ -735,7 +751,7 @@ impl App { submitted_query: String::new(), config_path, config, - watch_later, + video_state, watch_removal: None, key_hints, feed_channels, @@ -768,7 +784,7 @@ impl App { .filter(|(_, view)| match &view.source { ViewSource::Search | ViewSource::Feed => true, ViewSource::Category(name) => self.config.pinned_categories.contains(name), - ViewSource::Channel(_) | ViewSource::WatchLater(_) => false, + ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) => false, }) .chain(self.temporary_view.as_ref().and_then(|source| { self.views.iter().enumerate().find(|(_, view)| { @@ -1059,8 +1075,10 @@ impl App { fn activate_view(&mut self, index: usize) { let source = &self.views[index].source; - if matches!(source, ViewSource::Channel(_) | ViewSource::WatchLater(_)) - || matches!(source, ViewSource::Category(name) if !self.config.pinned_categories.contains(name)) + if matches!( + source, + ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) + ) || matches!(source, ViewSource::Category(name) if !self.config.pinned_categories.contains(name)) { self.temporary_view = Some(source.clone()); } @@ -1083,9 +1101,9 @@ impl App { self.close_preview(); if matches!( self.views[self.view_index].source, - ViewSource::WatchLater(_) + ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) ) { - self.rebuild_watch_later(); + self.rebuild_collections(); self.status = self.view_status(); return; } @@ -1099,9 +1117,9 @@ impl App { } if matches!( self.views[self.view_index].source, - ViewSource::WatchLater(_) + ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) ) { - self.rebuild_watch_later(); + self.rebuild_collections(); self.status = self.view_status(); return; } @@ -1151,6 +1169,13 @@ impl App { view.watch_filter.label() ); } + if matches!(view.source, ViewSource::SavedVideos(_)) { + return format!( + "{} videos in {}. Saved metadata is available offline.", + view.videos.len(), + view.name + ); + } if let Some(error) = &view.page_error { return format!( "{} videos kept in {}. Page {} failed: {error}. Refresh this view to retry.", @@ -1216,24 +1241,32 @@ impl App { && self.views[self.view_index].needs_page() } - fn rebuild_watch_later(&mut self) { + fn rebuild_collections(&mut self) { let mut active_changed = false; for (index, view) in self.views.iter_mut().enumerate() { - let ViewSource::WatchLater(category) = &view.source else { - continue; + let (kind, category) = match &view.source { + ViewSource::WatchLater(category) => (CollectionKind::WatchLater, category), + ViewSource::SavedVideos(category) => (CollectionKind::Saved, category), + _ => continue, + }; + view.title = if matches!(kind, CollectionKind::WatchLater) { + format!(" {} · {} ", view.name, view.watch_filter.label()) + } else { + format!(" {} ", view.name) }; - view.title = format!(" {} · {} ", view.name, view.watch_filter.label()); let selected = view.selection.selected(); let selected_id = selected .and_then(|row| view.videos.get(row)) .map(|video| video.id.clone()); view.videos = self - .watch_later - .videos() + .video_state + .videos(kind) .iter() .filter(|video| { - view.watch_filter - .includes(self.watch_later.is_watched(&video.id)) + (matches!(kind, CollectionKind::Saved) + || view + .watch_filter + .includes(self.video_state.is_watched(&video.id))) && category.as_ref().is_none_or(|name| { self.config.categories.get(name).is_some_and(|channels| { video_in_channels(video, channels, &self.config) @@ -1254,11 +1287,10 @@ impl App { view.has_more = false; view.page_error = None; if index == self.view_index { - active_changed = selected != selection - || selected_id.as_deref() - != selection - .and_then(|row| view.videos.get(row)) - .map(|video| video.id.as_str()); + active_changed = selected_id.as_deref() + != selection + .and_then(|row| view.videos.get(row)) + .map(|video| video.id.as_str()); } } if active_changed { @@ -1266,19 +1298,28 @@ impl App { } } - fn browse_watch_later(&mut self) { + fn browse_collection(&mut self, kind: CollectionKind) { let category = match &self.views[self.view_index].source { - ViewSource::Category(name) | ViewSource::WatchLater(Some(name)) => Some(name.clone()), + ViewSource::Category(name) + | ViewSource::WatchLater(Some(name)) + | ViewSource::SavedVideos(Some(name)) => Some(name.clone()), _ => None, }; - let name = category.as_ref().map_or_else( - || "Watch later".into(), - |name| format!("{name} · Watch later"), - ); - self.browse_view(name, ViewSource::WatchLater(category)); + let label = match kind { + CollectionKind::WatchLater => "Watch later", + CollectionKind::Saved => "Saved videos", + }; + let name = category + .as_ref() + .map_or_else(|| label.into(), |name| format!("{name} · {label}")); + let source = match kind { + CollectionKind::WatchLater => ViewSource::WatchLater(category), + CollectionKind::Saved => ViewSource::SavedVideos(category), + }; + self.browse_view(name, source); } - fn save_watch_later(&mut self) { + fn save_collection(&mut self, kind: CollectionKind) { let view = &self.views[self.view_index]; let Some(video) = view .selection @@ -1288,14 +1329,15 @@ impl App { self.status = "Select a video first.".into(); return; }; - let already_saved = self.watch_later.contains(&video.id); - match self.watch_later.save(video) { + let already_saved = self.video_state.contains(kind, &video.id); + match self.video_state.save(kind, video) { Ok(()) => { - self.rebuild_watch_later(); - self.status = if already_saved { - "Video is already in watch later." - } else { - "Video saved to watch later." + self.rebuild_collections(); + self.status = match (kind, already_saved) { + (CollectionKind::WatchLater, true) => "Video is already in watch later.", + (CollectionKind::WatchLater, false) => "Video saved to watch later.", + (CollectionKind::Saved, true) => "Video is already in saved videos.", + (CollectionKind::Saved, false) => "Video saved to saved videos.", } .into(); } @@ -1309,10 +1351,34 @@ impl App { return; } view.watch_filter = view.watch_filter.next(); - self.rebuild_watch_later(); + self.rebuild_collections(); self.status = self.view_status(); } + fn remove_saved_video(&mut self) { + let view = &self.views[self.view_index]; + let Some(video) = view + .selection + .selected() + .and_then(|row| view.videos.get(row)) + else { + self.status = "Select a video first.".into(); + return; + }; + if !self.video_state.contains(CollectionKind::Saved, &video.id) { + self.status = "Video is not in saved videos.".into(); + return; + } + let id = video.id.clone(); + match self.video_state.remove(CollectionKind::Saved, &[id]) { + Ok(()) => { + self.rebuild_collections(); + self.status = "Video removed from saved videos.".into(); + } + Err(error) => self.status = error.to_string(), + } + } + fn remove_watch_later(&mut self) { let view = &self.views[self.view_index]; let ViewSource::WatchLater(category) = &view.source else { @@ -1384,7 +1450,7 @@ impl App { // Unsaved channels opened from video rows remain browseable without subscribing. self.views.extend(old.into_iter().filter(|view| { matches!(&view.source, ViewSource::Channel(identity) if !self.feed_channels.contains(identity)) - || matches!(&view.source, ViewSource::WatchLater(category) if category.as_ref().is_none_or(|name| config.categories.contains_key(name))) + || matches!(&view.source, ViewSource::WatchLater(category) | ViewSource::SavedVideos(category) if category.as_ref().is_none_or(|name| config.categories.contains_key(name))) })); self.view_index = self .views @@ -1393,7 +1459,7 @@ impl App { .unwrap_or(0); self.feed_channels = feed_channels; self.config = config; - self.rebuild_watch_later(); + self.rebuild_collections(); if self.temporary_view.as_ref().is_some_and(|source| { !self.views.iter().any(|view| &view.source == source) || matches!(source, ViewSource::Category(name) if self.config.pinned_categories.contains(name)) @@ -1402,7 +1468,7 @@ impl App { } if matches!( &self.views[self.view_index].source, - ViewSource::WatchLater(_) + ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) ) || matches!(&self.views[self.view_index].source, ViewSource::Category(name) if !self.config.pinned_categories.contains(name)) { self.temporary_view = Some(self.views[self.view_index].source.clone()); @@ -1714,7 +1780,7 @@ impl App { return; }; self.player_launch = None; - let progress_file = match self.watch_later.progress_file() { + let progress_file = match self.video_state.progress_file() { Ok(path) => path, Err(error) => { self.status = error.to_string(); @@ -1762,9 +1828,9 @@ impl App { Err(error) => error, }; } - match self.watch_later.poll_progress() { + match self.video_state.poll_progress() { Ok(true) => { - self.rebuild_watch_later(); + self.rebuild_collections(); if matches!( self.views[self.view_index].source, ViewSource::WatchLater(_) @@ -1845,9 +1911,12 @@ impl App { match key.code { KeyCode::Char('y') if key.modifiers.is_empty() => { let removal = self.watch_removal.take().expect("removal is active"); - match self.watch_later.remove(&removal.ids) { + match self + .video_state + .remove(CollectionKind::WatchLater, &removal.ids) + { Ok(()) => { - self.rebuild_watch_later(); + self.rebuild_collections(); self.status = format!("Removed {} watch-later videos.", removal.ids.len()); } @@ -1967,10 +2036,15 @@ impl App { Some(Action::ViewRefresh) => self.refresh(), Some(Action::ViewTabNext) => self.cycle_view(false), Some(Action::ViewTabPrevious) => self.cycle_view(true), - Some(Action::WatchLaterSave) => self.save_watch_later(), - Some(Action::WatchLaterBrowse) => self.browse_watch_later(), + Some(Action::WatchLaterSave) => self.save_collection(CollectionKind::WatchLater), + Some(Action::WatchLaterBrowse) => { + self.browse_collection(CollectionKind::WatchLater) + } Some(Action::WatchLaterFilter) => self.filter_watch_later(), Some(Action::WatchLaterRemove) => self.remove_watch_later(), + Some(Action::SavedVideoSave) => self.save_collection(CollectionKind::Saved), + Some(Action::SavedVideoBrowse) => self.browse_collection(CollectionKind::Saved), + Some(Action::SavedVideoRemove) => self.remove_saved_video(), Some(Action::VideoPlay) => self.play(), Some(Action::VideoNext) => self.views[self.view_index].selection.select_next(), Some(Action::VideoPrevious) => { @@ -2096,12 +2170,17 @@ impl App { .any(|channels| video_in_channels(video, channels, &self.config)); let channel = Cell::from(video.channel.as_deref().unwrap_or("Unknown")); Row::new([ - Cell::from(if self.watch_later.is_watched(&video.id) { + Cell::from(if self.video_state.is_watched(&video.id) { "✓" } else { "" }), - if self.watch_later.contains(&video.id) { + if self.video_state.contains(CollectionKind::Saved, &video.id) { + Cell::from(video.title.as_str()).fg(Color::Green) + } else if self + .video_state + .contains(CollectionKind::WatchLater, &video.id) + { Cell::from(video.title.as_str()).fg(Color::Magenta) } else { Cell::from(video.title.as_str()) @@ -2128,10 +2207,10 @@ impl App { } else { view.title.as_str() }, - if matches!(view.source, ViewSource::WatchLater(_)) { - self.key_hints.watch_later.as_str() - } else { - "" + match view.source { + ViewSource::WatchLater(_) => self.key_hints.watch_later.as_str(), + ViewSource::SavedVideos(_) => self.key_hints.saved_videos.as_str(), + _ => "", }, self.job.is_some().then_some(spinner), ))) @@ -2697,7 +2776,7 @@ fn main() -> io::Result<()> { } let config_path = config::config_path()?; let config = config::load(&config_path)?; - let state = watch_later::State::open(&config_path)?; + let state = video_state::State::open(&config_path)?; let app = App::with_state(options, config_path, config, state); ratatui::run(|terminal| app.run(terminal)) } @@ -2722,7 +2801,7 @@ mod tests { )); let path = root.join("config.toml"); let _ = std::fs::remove_dir_all(&root); - let state = watch_later::State::open(&path).unwrap(); + let state = video_state::State::open(&path).unwrap(); let channel = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap(); let config = config::Config { channels: [("@creator".into(), channel.clone())].into(), @@ -2740,11 +2819,11 @@ mod tests { ).unwrap().videos; app.views[0].loaded = true; app.views[0].selection.select(Some(0)); - app.save_watch_later(); - app.save_watch_later(); + app.save_collection(CollectionKind::WatchLater); + app.save_collection(CollectionKind::WatchLater); app.views[0].selection.select(Some(1)); - app.save_watch_later(); - assert_eq!(app.watch_later.videos().len(), 2); + app.save_collection(CollectionKind::WatchLater); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); app.views[0].selection.select(None); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); @@ -2762,11 +2841,11 @@ mod tests { // An empty loaded category avoids extraction while opening its derived offline list. app.views[2].loaded = true; app.switch_view(2); - app.browse_watch_later(); + app.browse_collection(CollectionKind::WatchLater); let category_source = ViewSource::WatchLater(Some("Category".into())); assert_eq!(app.views[app.view_index].source, category_source); assert_eq!(app.views[app.view_index].videos.len(), 1); - app.browse_watch_later(); + app.browse_collection(CollectionKind::WatchLater); assert_eq!(app.views[app.view_index].source, category_source); assert!(app.job.is_none()); @@ -2793,7 +2872,7 @@ mod tests { assert_eq!(app.view_history, history); app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); assert!(app.watch_removal.is_none()); - assert_eq!(app.watch_later.videos().len(), 2); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); app.apply_config(config::Config { channels: [("@creator".into(), channel.clone())].into(), @@ -2841,12 +2920,15 @@ mod tests { assert_eq!(app.views[app.view_index].videos.len(), 2); assert_eq!(app.watch_removal.as_ref().unwrap().ids, ["jNQXAC9IVRw"]); app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert_eq!(app.watch_later.videos().len(), 1); - assert!(app.watch_later.contains("rQ_J9WH6CGk")); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 1); + assert!( + app.video_state + .contains(CollectionKind::WatchLater, "rQ_J9WH6CGk") + ); assert_eq!(app.views[app.view_index].videos.len(), 1); app.switch_view(0); - app.browse_watch_later(); + app.browse_collection(CollectionKind::WatchLater); assert_eq!( app.views[app.view_index].source, ViewSource::WatchLater(None) @@ -2859,9 +2941,9 @@ mod tests { Options::parse_from(["tubular"]), path.clone(), config::Config::default(), - watch_later::State::open(&path).unwrap(), + video_state::State::open(&path).unwrap(), ); - restarted.browse_watch_later(); + restarted.browse_collection(CollectionKind::WatchLater); assert!(restarted.job.is_none()); assert!(restarted.views[restarted.view_index].loaded); assert_eq!( @@ -2870,12 +2952,318 @@ mod tests { ); restarted.remove_watch_later(); restarted.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(restarted.watch_later.videos().is_empty()); + assert!( + restarted + .video_state + .videos(CollectionKind::WatchLater) + .is_empty() + ); drop(restarted); - assert!(watch_later::State::open(&path).unwrap().videos().is_empty()); + assert!( + video_state::State::open(&path) + .unwrap() + .videos(CollectionKind::WatchLater) + .is_empty() + ); std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn saved_views_follow_current_categories_and_restore_offline_history() { + let root = std::env::temp_dir().join(format!("tubular-saved-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("config.toml"); + std::fs::write( + root.join("watch-state.toml"), + "version = 1\nwatched = [\"jNQXAC9IVRw\"]\n", + ) + .unwrap(); + let channel = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap(); + let other = config::normalize_channel("@other").unwrap(); + let config = config::Config { + channels: [("@creator".into(), channel.clone())].into(), + categories: [ + ("Category".into(), vec![channel.clone(), other.clone()]), + ("Overlap".into(), vec![channel]), + ] + .into(), + ..Default::default() + }; + let mut app = App::with_state( + Options::parse_from(["tubular"]), + path.clone(), + config, + video_state::State::open(&path).unwrap(), + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Mapped creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other creator","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + for row in 0..2 { + app.views[0].selection.select(Some(row)); + app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + } + app.views[2].loaded = true; + app.switch_view(2); + app.key(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT)); + let saved_category = ViewSource::SavedVideos(Some("Category".into())); + assert_eq!(app.views[app.view_index].source, saved_category); + assert_eq!(app.views[app.view_index].videos.len(), 2); + app.views[app.view_index].selection.select(Some(1)); + app.key(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::WatchLater(Some("Category".into())) + ); + app.filter_watch_later(); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert!(app.views[app.view_index].title.contains("Unwatched")); + app.key(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT)); + assert_eq!(app.views[app.view_index].source, saved_category); + assert_eq!( + app.views[app.view_index].videos.len(), + 2, + "saved lists ignore watch filters" + ); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + let mut config = config::Config { + channels: app.config.channels.clone(), + categories: app.config.categories.clone(), + ..Default::default() + }; + config.categories.insert("Category".into(), vec![other]); + app.apply_config(config); + assert_eq!(app.views[app.view_index].source, saved_category); + assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk"); + assert_eq!(app.views[app.view_index].selection.selected(), Some(0)); + let watch = app + .views + .iter() + .find(|view| matches!(view.source, ViewSource::WatchLater(Some(_)))) + .unwrap(); + assert_eq!(watch.watch_filter, WatchFilter::Unwatched); + assert!(watch.title.contains("Unwatched")); + app.switch_view(0); + app.key(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(None) + ); + assert_eq!( + app.views[app.view_index].videos.len(), + 2, + "overlapping categories do not duplicate videos" + ); + app.views[app.view_index].selection.select(Some(1)); + app.back_view(); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + app.back_view(); + assert_eq!(app.views[app.view_index].source, saved_category); + app.forward_view(); + app.forward_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(None) + ); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + assert_eq!(app.visible_views().count(), 3); + assert!(app.job.is_none()); + app.views[app.view_index].selection.select(Some(0)); + app.preview = Some(PreviewMode::Info); + app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!( + app.preview.is_none(), + "removing the selected row dismisses its preview" + ); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert!( + app.video_state + .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") + ); + assert!(app.video_state.is_watched("jNQXAC9IVRw")); + let config = std::mem::take(&mut app.config); + drop(app); + let mut restarted = App::with_state( + Options::parse_from(["tubular"]), + path.clone(), + config, + video_state::State::open(&path).unwrap(), + ); + restarted.browse_collection(CollectionKind::Saved); + let view = &restarted.views[restarted.view_index]; + assert!(view.loaded && !view.has_more); + assert_eq!(view.videos.len(), 1); + assert_eq!(view.videos[0].title, "Other creator"); + assert!(restarted.job.is_none()); + assert!(restarted.video_state.is_watched("jNQXAC9IVRw")); + assert_eq!( + restarted + .video_state + .videos(CollectionKind::WatchLater) + .len(), + 2 + ); + drop(restarted); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn saved_actions_use_configured_keys_in_each_video_context_and_remove_only_selection() { + let mut app = app(&["tubular"]); + let document = "[keys]\nvideo.saved.save = 'f6'\nview.saved.browse = 'f7'\nvideo.saved.remove = 'f8'\n" + .parse::().unwrap(); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Missing creator"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(1)); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + app.views[0].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + app.preview = preview; + app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + assert!( + !app.video_state + .contains(CollectionKind::Saved, "jNQXAC9IVRw"), + "disabled default must not save" + ); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + assert_eq!(app.preview, preview); + app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1); + assert!( + app.video_state + .contains(CollectionKind::Saved, "rQ_J9WH6CGk") + ); + assert!( + app.video_state + .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") + ); + assert_eq!( + app.preview, preview, + "removal from an unchanged search row retains preview" + ); + app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + assert!(app.status.contains("not in saved")); + assert_eq!(app.preview, preview); + } + app.preview = None; + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + app.views[0].selection.select(Some(2)); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + assert!(app.status.contains("channel identity")); + assert!( + !app.video_state + .contains(CollectionKind::Saved, "l-GaaivVJCE") + ); + app.views[0].selection.select(None); + app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + assert!(app.status.contains("Select a video")); + app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(None) + ); + assert!(app.job.is_none()); + assert!(app.key_hints.footer(Context::Normal).contains("F6/F7")); + assert!(app.key_hints.saved_videos.contains("F8")); + app.key(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT)); + app.key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT)); + assert!(app.watch_removal.is_some()); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!( + app.video_state + .videos(CollectionKind::WatchLater) + .is_empty() + ); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + assert!(!app.video_state.is_watched("jNQXAC9IVRw")); + } + + #[test] + fn saved_green_precedes_watch_later_magenta_without_changing_creator_or_selection_colors() { + let mut app = app(&["tubular"]); + app.config.categories.insert( + "Tagged".into(), + vec![config::normalize_channel("@creator").unwrap()], + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Saved title","channel":"Creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Later title","channel":"Other","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + for row in 0..2 { + app.views[0].selection.select(Some(row)); + app.save_collection(CollectionKind::WatchLater); + } + app.views[0].selection.select(Some(0)); + app.save_collection(CollectionKind::Saved); + app.views[0].selection.select(None); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!( + buffer + .content + .iter() + .any(|cell| cell.symbol() == "S" && cell.fg == Color::Green) + ); + assert!( + buffer + .content + .iter() + .any(|cell| cell.symbol() == "L" && cell.fg == Color::Magenta) + ); + assert!( + buffer + .content + .iter() + .any(|cell| cell.symbol() == "C" && cell.fg == Color::Cyan) + ); + app.views[0].selection.select(Some(0)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .any(|cell| cell.symbol() == "S" + && cell.fg == Color::Black + && cell.bg == Color::Cyan) + ); + app.remove_saved_video(); + app.views[0].selection.select(None); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .any(|cell| cell.symbol() == "S" && cell.fg == Color::Magenta) + ); + } + #[test] fn paste_targets_only_active_inputs_and_refilters_without_executing_shortcuts() { let mut app = app(&["tubular"]); @@ -3084,6 +3472,7 @@ mod tests { let document = r#"[keys] video.info.overview = "I" video.info.hide = "x" +video.saved.remove = [] search.edit = "f2" input.delete.word = "ctrl+backspace" input.delete.line = "ctrl+l" diff --git a/src/watch_later.rs b/src/video_state.rs similarity index 86% rename from src/watch_later.rs rename to src/video_state.rs index 13fc8bb..ba27fd8 100644 --- a/src/watch_later.rs +++ b/src/video_state.rs @@ -55,11 +55,57 @@ impl StoredFile { } } -pub struct State { - collection: StoredFile, - watch_state: StoredFile, +#[derive(Clone, Copy)] +pub enum CollectionKind { + WatchLater, + Saved, +} + +struct Collection { + file: StoredFile, videos: Vec