tubular
A modal terminal YouTube browser built with Rust and Ratatui. 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-dlponPATHfor searches, feeds, and full descriptions.curlonPATHfor 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:
cargo install --path . --locked
Make sure Cargo's install directory (normally ~/.cargo/bin) is on your PATH.
To run directly from source instead:
cargo run --locked -- "rust programming"
Usage
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:
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/kor arrow keys navigate; Space checks/unchecks a category;ienters filter mode; Enter browses checked categories, or the hovered category if none are checked;ptoggles its persisted pin;Rrenames 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
psuffix 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/kor arrow keys navigate;ienters 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.
sstill edits the selected video's channel tags; bulkSremains 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. 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/kor arrow keys navigate; Space toggles a tag;iedits the filter;Rrenames 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.
nprompts 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, andtags.input.previous; text editing uses the sharedinput.*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:
$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.
[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 pressr. - 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:
[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:
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.
[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 document the native tubular::miruro wrapper and verified transport behavior. Miruro is not yet integrated into the TUI or Terebi playback.
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):
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.