# 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 current category/combined feed's search, or general Search from other views | | `/` | Go directly to general YouTube Search | | `F` (Shift-F) | Go directly to the subscription Feed | | `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 | | `R` (Shift-R) | Rename the current category, including category-scoped collection views | | `P` (Shift-P) | Save the current combined-category feed as a new category | | `w` | Save the selected video to watch later (also works in previews) | | `W` | Choose combined or category watch later from any video view or preview | | `Alt-w` | Open the current category's watch later | | `f` | In watch later, cycle All → Unwatched → Watched | | `x` | In watch-later, saved-video, or currently-watching lists, remove the selected video | | `X` (Shift-X) | In collection lists, confirm bulk removal of the current list/category/filter subset | | `v` | Save the selected video to saved videos (also works in previews) | | `V` | Choose combined or category saved videos from any video view or preview | | `Alt-v` | Open the current category's saved videos | | `C` (Shift-C) | Open the combined Currently watching list | | 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, new-tag, and category-rename fields: | Key | Action | | --- | --- | | Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, stage a new tag, or commit a rename | | Esc | Leave insert mode or abandon the new-tag / rename prompt | | 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; Space checks/unchecks a category; `i` enters filter mode; Enter browses checked categories, or the hovered category if none are checked; `p` toggles its persisted pin; `R` renames it; Esc closes. - **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses the chosen categories; Esc returns to normal mode without clearing the filter. Space is ordinary input here. - Checkboxes select categories for browsing; the dimmed `p` suffix marks pins independently. Hidden checks survive filtering. Enter still browses checked categories when there are no visible matches; without checks, an empty result makes browsing a no-op. Pinning, renaming, and toggling require a visible selected row. ### Category search Press `i` in a category or combined-category feed to search only its channels' histories—not just already loaded videos. Enter submits a changed query and returns to normal mode; an unchanged query keeps cached results and pagination. Clear the field and submit to restore the ordinary upload feed. Esc leaves the draft unsubmitted. Each category and combined feed retains its own draft, submitted query, results, and pagination independently of general Search. Subsequent pages use the same scoped query and shared 60-video budget. Empty categories and failed scoped requests never fall back to global search. Search input in other views still opens general YouTube Search. The direct Search and Feed jumps use the configurable `view.search` and `view.feed` actions. They preserve cached views and history, and do not run inside text inputs or selectors. ### Combined category feeds Check multiple categories in `c`, then press Enter to open their combined feed. One checked category opens its ordinary view; no checks retain the usual single-category shortcut. Esc discards the selector's staged choices. Reopening `c` from a combined feed prechecks its member categories. The combined feed uses the shared temporary tab, replacing the prior unpinned category/channel/collection view. Channels and video IDs are deduplicated, and videos use the same newest-first ordering and infinite scrolling as other feeds. Back/forward navigation restores its query, cached rows, selection, and pagination. Press `P` to save the viewed union of channels under a new category name. Enter commits; Esc cancels; duplicate or invalid names leave the prompt open without overwriting anything. The new flat category becomes active without automatic pinning or re-fetching matching cached pages. Its memberships are an independent copy, not links to the original categories. Renaming a member category updates the combined view and its history. Membership changes recompute the channel union, preserving cached rows when the resolved union is unchanged; deleting a member invalidates the combined view rather than silently widening it. Explicit refresh still reloads the active view from page one. Combined feeds are not individual categories: `S`, `R`, and `Alt-w` / `Alt-v` require a single category. Save with `P` first to use those actions. Configure multi-selection with `selector.toggle` and saving with `category.save`; name entry uses the shared `input.*` controls. ### 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) When search results omit a channel handle, `s` reuses its saved mapping or looks up the handle from the channel's canonical ID before opening the editor. Esc cancels a pending lookup without saving. Failed lookups or mismatched channel metadata leave the config unchanged. Tubular validates handles as channel addresses, not against [YouTube's current handle-creation guidelines](https://support.google.com/youtube/answer/11585688). Existing handles such as `@hontame_` keep their leading/trailing separators and round-trip through saved mappings and categories. Whitespace inside handles, control characters, slashes, query strings, and fragments remain rejected. - **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter; `R` renames the selected tag. - **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 those staged changes; it does not undo a committed rename of an existing category. 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. ### Renaming categories (`R`) In normal mode, press `R` (Shift-R) to rename the active category, or the hovered category in a category/collection selector or single-channel/bulk tag list. Combined collection entries and channel rows cannot be renamed. The floating prompt starts with the current name. Edit it with the shared text controls (`Ctrl-U` clears it), then press Enter to commit or Esc to cancel. Names are trimmed and must be nonempty, free of control characters, and distinct from existing or staged category names. Errors leave the prompt open without changing the file. If the source category changed externally, close dialogs and reload config with `r` before retrying. Renaming an existing category saves its identity immediately while preserving channel memberships, pins, comments, cached videos, selection, pagination, watch filters, and back/forward history. In tag dialogs, checkbox changes remain staged until the main tag-list Enter; cancelling them does not undo the rename. A newly staged, unsaved tag stays local when renamed and reaches disk only when the tags are saved. Configure the shortcut with `category.rename`; the prompt uses the shared `input.*` actions. `R` remains ordinary text while editing search or filters. ## 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, combined-category feed, 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. `W` opens a searchable collection selector from any video view or preview. The All categories (combined) entry comes first, followed by configured categories; it remains available even when there are no categories. Navigate with `j` / `k` or arrows in normal mode, or press `i` to filter by name and use arrows / `Ctrl-j` / `Ctrl-k` while typing. `Enter` browses the selected match; with no matches it does nothing. `Esc` returns to normal mode, then closes the selector without navigating. Reopening clears the search. `Alt-w` still opens the current category's watch later directly. Moving between watch-later scopes carries the current All/Unwatched/Watched filter to the destination, including cached lists; back navigation restores the originating category list. Choosing the current list does not add history. The border title shows the active filter. Lists use saved title, creator, and publication metadata without fetching video lists, so they also work offline. 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. ### Storage Files are siblings of `config.toml`: - `watch-later.toml`: versioned saved-video metadata. - `saved-videos.toml`: independent versioned saved-video metadata. - `currently-watching.toml`: versioned metadata for started, unfinished videos. - `watch-state.toml`: versioned global watched video IDs. - `playback-sessions/`: private per-launch progress streams, expected video identities, video snapshots, and durable admission markers. 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. `V` opens the same searchable selector for saved videos, with **All categories (combined)** first and configured categories beneath it. `Alt-v` opens the current category's saved videos directly. `Alt-w` / `Alt-v` retain the explicit category when switching between category-scoped collection lists; without a current category, they leave the view unchanged. All four shortcuts work in normal mode and previews, not text-entry fields or modal selectors. Both collections share the existing temporary-tab and back/forward history behavior. Saved lists have no watched filter. Individual and bulk removal use the shared collection-list controls below. 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 saving and the collection selector with `video.saved.save` and `view.saved.browse`; direct category browsing uses `view.saved.category`. The corresponding watch-later navigation actions are `view.watch_later.browse` and `view.watch_later.category`. Selectors use the shared `selector.*` and `input.*` bindings. The live hints and `?` reference reflect overrides. Alt shortcuts require the terminal to transmit Alt/Meta; if macOS Option inserts symbols, enable Option-as-Meta or remap the actions. ## Currently watching `C` opens one combined, offline list of started but unfinished videos. It uses the shared temporary tab and back/forward navigation; configure the shortcut with `view.watching.browse`. A validated, acknowledged **positive playback position** adds the video—not merely launching Terebi or receiving position zero. Reaching the existing eligible **95% watched threshold** automatically removes it. Already-watched videos never enter. Unknown-duration or ineligible playback can appear but stays unfinished unless a later eligible report marks it watched or you remove it. Entries and their title/creator/publication metadata survive restarts. Reports written while Tubular is closed are recovered on the next launch. Older sessions without video snapshots can populate the list when matching watch-later/saved metadata exists; otherwise they continue updating watched status without inventing missing metadata. Use `x` for individual removal or `X` for bulk confirmation. Removal neither marks the video watched nor changes watch later/saved videos. Continued reports from existing sessions cannot re-add a removed entry, including after restart; deliberately starting a new session can re-add it if still unfinished. ## Collection removal Removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. Search, Feed, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls. `x` immediately removes only the selected video from the active collection. `X` snapshots the current list and opens bulk confirmation: watch later uses the current category and All/Unwatched/Watched filter; saved videos uses the current category's entire saved list; Currently watching uses its entire combined list. An empty list does not open confirmation. The prompt names the collection, category, count, and applicable filter. **`y` or Enter confirms; `n` or Esc cancels.** Only the snapshotted IDs are removed, even if membership or watched status changes while confirmation is open. Removal changes only the active collection. Matching views update, while other collections and global watched status remain untouched. The old collection-specific removal settings are no longer accepted. Replace them with: ```toml [keys] view.collection.remove.selected = "x" view.collection.remove.bulk = "X" ``` ## 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 General Search loads up to 60 results per page. Feed and category views, including scoped category searches, share a **60-video total request budget per load**, distributed fairly across active channels—not 60 per channel. Each channel retains its own offset; exhausted channels stop receiving requests, and feeds with more than 60 channels rotate through them across loads. Matching ranges are batched across at most four concurrent yt-dlp processes, with further batches queued. Individual channel views can use the full 60-row budget. In normal mode, forward video navigation authorizes prefetch when 20 loaded rows remain. Each successful page clears that intent, so short, duplicate-only, or unavailable batches do not immediately chain additional loads at startup. Only one page request runs at a time; insert mode and open popups pause automatic prefetch. A feed page is accepted only after every batch succeeds. Switching views cancels running and queued batches; returning resumes the same unaccepted per-channel ranges. 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. Raw entries, including skipped and duplicate rows, consume the request budget and determine each channel's exhaustion; a full unavailable batch therefore does not hide later results. Invalid JSON, malformed playlist-level metadata, missing/extra playlists, oversized responses, and extractor failures still fail the page without advancing channel offsets. Feed views sort all loaded videos newest-first using available upload dates, with undated videos last. The budget limits what has been inspected: later loads may discover newer videos and insert them above older rows, while selection and pending description requests stay attached to the same video ID. 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 [Miruro API notes](docs/miruro-api.md) document the native `tubular::miruro` wrapper and verified transport behavior. Miruro is not yet integrated into the TUI or Terebi playback. ```sh cargo fmt --check cargo test --locked cargo clippy --locked --all-targets -- -D warnings ``` Read-only native Miruro smoke (search → detail/episodes → fresh sources → HLS master/subtitle): ```sh cargo run --locked --example miruro_probe ``` This uses the documented research title and direct Sun provider, stops on errors/protection pages, and does not download media segments or save tokenized URLs. The sample can become unavailable; passing it proves resource retrieval, not full player playback.