# tubular A modal terminal YouTube browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos, organize subscribed channels with overlapping tags, browse their feeds, and open a selected video in Terebi. ## Requirements - A recent stable Rust toolchain, including Cargo, to build and install. - [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for searches, feeds, and full descriptions. - `curl` on `PATH` for thumbnails. - The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. It must support `--new-instance --url … --progress-file PATH`. The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player. A terminal with native image support can display thumbnails using its graphics protocol. Other terminals fall back to colored half-block characters. ## Installation From a checkout of this repository: ```sh cargo install --path . --locked ``` Make sure Cargo's install directory (normally `~/.cargo/bin`) is on your `PATH`. To run directly from source instead: ```sh cargo run --locked -- "rust programming" ``` ## Usage ```sh tubular # Start without a query tubular "rust programming" # Search immediately tubular --player /path/to/terebi "japanese podcast" tubular --help ``` Tubular starts in **normal mode**, with or without a command-line query. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting. Selecting a video launches: ```sh terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \ --progress-file '/private/session/progress.jsonl' ``` Each selection opens an independent player instance without replacing existing playback. Tubular creates a private reporting session and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running. ## Controls These are the default bindings. Overrides in `[keys]` replace an action's bindings; the live hints and `?` reference show the configured keys. ### Normal mode | Key | Action | | --- | --- | | `j` / `k`, ↓ / ↑ | Select the next / previous video | | Enter | Open the selected video in Terebi | | `g` / Home, `G` / End | Select the first / last loaded video | | `i` | Edit the search query | | `d` | Open video info; press again for the full description, again to return to info | | Esc | Close the preview; otherwise return to the previous view | | `-` | Return to the previous view | | `+` | Go forward after navigating back | | `c` | Browse saved categories | | `u` | Browse an individual saved channel | | `U` (Shift-U) | Open the selected video's channel | | `s` | Edit the selected channel's tags | | `S` | Edit tags for all saved channels in the current category | | `w` | Save the selected video to watch later (also works in previews) | | `W` | Browse watch later for the current category, or the combined collection elsewhere | | `f` | In watch later, cycle All → Unwatched → Watched | | `X` (Shift-X) | Remove only the selected watch-later video (also works in previews) | | `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 | | `q` | Quit | **Ctrl-C quits from any mode or popup.** The bottom bar shows essential actions for the current context. Press `?` in normal mode—including selectors and previews—for the full reference. Scroll help with `j` / `k`, arrows, Ctrl-J / Ctrl-K, or PageDown / PageUp; `?` or Esc closes it without changing the underlying view or dialog. In text fields, `?` remains ordinary input. The overview groups shortcut/action rows under bold section headers such as `VIEWS AND VIDEOS`, with an empty line beneath each header. Shortcuts and action descriptions remain normal weight; the rows reflect configured bindings rather than showing internal action paths. Back and forward navigation restore cached videos, selection, and pagination, including replaced temporary tabs. Removed views are skipped; empty history leaves the current view unchanged. Opening a different view after going back discards the old forward branch. Esc first closes a dialog or leaves insert mode without navigating. Minus and plus remain ordinary text inside input fields. The full-description view scrolls with ↓ / ↑, Ctrl-J / Ctrl-K, or PageDown / PageUp. Plain `j` / `k` still changes the selected video. The bold title, dimmed author/publication row, and description scroll together. ### Text input These shortcuts work in search, category/channel/tag-filter, and new-tag fields: | Key | Action | | --- | --- | | Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, 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 | | Cmd/Super-Backspace or Ctrl-U | Clear the line | If the terminal intercepts modified Backspace rather than transmitting it, use Ctrl-W / Ctrl-U or configure equivalent terminal key mappings. Input fields show a block cursor. In terminals supporting bracketed paste, clipboard text is inserted in one operation into the active search, filter, or new-tag field; filters update once for the whole paste. Pasting never submits input or triggers shortcuts. Line breaks and tabs become spaces, other control characters are removed, and pastes outside text-entry mode are ignored. Bracketed-paste mode is disabled again when Tubular exits. Terminals without bracketed-paste support still deliver pasted text as ordinary keystrokes. ### Category browser (`c`) - **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; 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. - 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. - Channels and unpinned categories share **one temporary browsing tab**. Choosing either replaces the previous temporary view; pinned category tabs stay visible. Revisiting or navigating back restores 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. Press **`U` (Shift-U)** on a video to browse its creator directly, including channels you have not saved. This replaces the current temporary browsing tab without adding a subscription. Channel IDs are preferred; a valid handle is used when an ID is unavailable. Missing channel metadata leaves the current view unchanged. Unsaved channel views survive configuration changes for the session; explicitly removing a saved subscription still removes its channel view. ### Channel tags (`s`, or `S` for bulk editing) - **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter. - **Insert:** type a case-insensitive substring to filter live; arrow keys or Ctrl-J / Ctrl-K navigate matches. Enter or Esc returns to normal mode without saving or clearing the filter. - Filtering only hides rows: checked and unchecked staged memberships outside the filter are retained. With no matches, navigation and toggling do nothing. Reopening the dialog clears its filter. - `n` prompts for a new tag name. Enter stages it checked, clears the filter, and selects the new tag; Esc abandons the name. - Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels without writing. These controls apply to both single-channel and bulk tagging. - Filter actions are configurable as `tags.edit`, `tags.input.next`, and `tags.input.previous`; text editing uses the shared `input.*` actions. Bulk editing applies the exact checked tag set to **every saved channel in the current category**, not only channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Review the checked set before saving: unchecked tags are removed from all targets. Other channels, saved registrations, empty categories, pins, and comments are preserved. ## Subscriptions and feeds Configuration lives at: ```text $XDG_CONFIG_HOME/tubular/config.toml ``` If `XDG_CONFIG_HOME` is unset, empty, or relative, the fallback is `~/.config/tubular/config.toml`. Edit the file directly and press `r` to reload, or save memberships from the tag dialog. ```toml [settings] pinned_categories = ["Favorites"] [categories] Creators = ["@jawed"] Favorites = ["@jawed"] [channels] "@jawed" = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A" ``` `[categories]` contains **flat, overlapping tags**. A channel can belong to several categories; there is no hierarchy or inheritance. `[channels]` maps readable handles to stable channel URLs or IDs. Saving a channel through the UI records its handle and mapping when metadata is available. `[settings]` holds global options such as `pinned_categories`. The `[settings]`, `[channels]`, `[categories]`, and `[keys]` sections can appear in any order; pin/unpin and tag edits preserve the existing section order, key overrides, and other preferences. For older configs, add `[settings]` immediately above `pinned_categories`. A top-level declaration is no longer accepted. Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC…` channel IDs. Mapped handles use their stable identity; unmapped handles work directly. 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, 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. Bulk editing refuses to clear all tags when a target has no `[channels]` registration, preventing accidental unsubscription of category-only channels. If source memberships change while a bulk dialog is open, close and reopen it before saving. ## Watch later Watch later is **one collection**, deduplicated by YouTube video ID. Category lists are derived from the saved videos' channel identities and the **current** subscription categories: adding or removing a channel from a category changes its watch-later list after saving tags or reloading config with `r`. No category memberships are stored on individual videos. 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. 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 Terebi reports acknowledged playback positions. A video becomes watched when an eligible position reaches **95% of a known positive duration**. Seeking near the end counts; live/non-seekable, unknown-duration, and audio-only inputs do not. Launcher exit or window closure alone never marks a video watched. Watched status is global across categories and remains after removing a video from watch later. ### Removal `X` (Shift-X) immediately removes **only the selected video** from watch later, including from Search, category views, or previews. Other videos, saved-video membership, and watched status remain unchanged. Configure this action with `video.watch_later.remove`; it is separate from the bulk action `view.watch_later.remove`. `D` snapshots all videos matching the current list and filter, then shows their count, category, and filter. **`y` confirms; `n` or Esc cancels.** Enter does not confirm. Removal deletes those videos from the shared collection, so they disappear from every matching category; watched status is untouched. An empty filtered list has nothing to remove. ### Storage 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. State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB. 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: ```sh tubular --default-config ``` This prints TOML to stdout and exits without reading or writing config, starting the TUI, or needing `yt-dlp`, Terebi, or a terminal. Copy the settings you want into your config; `r` reloads them. ```toml [keys] video.info.overview = "I" video.info.description = "d" video.info.hide = ["escape", "backspace"] view.history.previous = ["-", "escape"] view.history.next = "+" video.play = ["enter", "ctrl+p"] ``` Each string or array **replaces** that action's default aliases; omitted actions keep their defaults, and `[]` disables an action. Dotted assignments, corresponding subtables such as `[keys.video.info]`, and quoted action names are supported. Unknown actions, invalid chords, duplicate action declarations, and ambiguous bindings in the same context are rejected before saving. Characters are case-sensitive (`i` differs from `I`). Named keys are case-insensitive: `escape`, `enter`, `tab`, `space`, arrows, `home`, `end`, `pageup`, `pagedown`, `backspace`, `delete`, and `f1`–`f24`. Combine modifiers with `+`, for example `ctrl+j`, `alt+backspace`, `cmd+backspace`, or `shift+tab`. `control`, `option`, and `super` are accepted aliases; `+` itself, or `ctrl++`, can be bound. The terminal must transmit the chord for it to work. Using the same binding for overview and description preserves the default cycle. Distinct bindings, as above, open either view directly. Preview close takes precedence over history back; description scrolling takes precedence over video selection for shared bindings. View commands do not run inside text inputs or selectors; those have their own actions listed by `--default-config`. ## Loading and previews 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. Malformed individual entries—including invalid video IDs, missing required fields, and wrong field types—are discarded without losing valid videos from the same page. Skipped entries still count toward pagination, so a full page of bad or unavailable entries does not hide later results. Invalid JSON, malformed playlist-level metadata, and extractor failures still fail the page. 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. Pane titles show animated spinners while content loads. Errors retain loaded rows and stop automatic retries; `r` refreshes the current view. Video info shows a thumbnail, title, author/publication details, and a clipped description excerpt. Press `d` again to read the full description in the same popup. Thumbnails and full descriptions are cached in memory across selections and views: 64 full descriptions and eight inactive decoded thumbnails, plus the selected image. Caches last for the session; a successful refresh clears them. Preview loading, decoding, and image resizing run asynchronously. ## Development ```sh cargo fmt --check cargo test --locked cargo clippy --locked --all-targets -- -D warnings ```