diff --git a/README.md b/README.md index 2487ff4..546f806 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,11 @@ tubular --player /path/to/terebi "japanese podcast" tubular --help ``` -Startup shows a centered **YouTube / Miruro** selector. Use `j` / `k` or arrows to select and Enter to open that provider's existing browser; Esc, `q`, or Ctrl-C quits without opening a browser. Navigation, confirmation and Esc use the configured `selector.select.next`, `selector.select.previous`, `selector.open` and `selector.close` bindings; quit/interrupt bindings also apply. An initial CLI query searches whichever provider you select. `--miruro` bypasses the chooser; `--help` and `--default-config` remain noninteractive. +Startup shows a centered **YouTube / Miruro** selector. Use `j` / `k` or arrows to select and Enter to open that provider's separate interface; Esc, `q`, or Ctrl-C quits without opening a browser. Navigation, confirmation and Esc use the configured `selector.select.next`, `selector.select.previous`, `selector.open` and `selector.close` bindings; quit/interrupt bindings also apply. An initial CLI query searches whichever provider you select. `--miruro` bypasses the chooser; `--help` and `--default-config` remain noninteractive. After selection, the browser starts in **normal mode**. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting. -Selecting a video launches: +Selecting a YouTube video launches: ```sh terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \ @@ -52,16 +52,33 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \ 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. -### Miruro episodes +### Miruro browser -Choose **Miruro** at startup, use `--miruro` to start directly, or reach the permanent **Miruro** tab with Tab / Shift-Tab. Press `i` to edit its search and Enter to submit. Autocomplete returns at most eight catalog titles, without pagination. Enter on a title loads its returned episodes; Enter on an episode resolves a fresh direct **ssub / sun** HLS source and launches Terebi with a private source document. `/` still opens YouTube Search. +Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, without YouTube's Search/Feed tabs, channel controls or category selectors. -Episodes support watch later, saved videos, currently watching, and watched status. Only episodes—not catalog titles—can be saved. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Miruro entries appear in combined collections, not YouTube channel categories. Channel/tag actions and YouTube thumbnails/extraction do not apply. +Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; the metadata pane shows the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata pane, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available. + +| Key | Miruro action | +| --- | --- | +| `i` / `/` | Edit the catalog query; Enter searches, Esc cancels | +| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode | +| Enter | Open a series, or resolve a fresh direct **ssub / sun** source and play the episode | +| `-` / `+` | Back / forward through cached screens, preserving query and selection | +| `w` / `v` | Save the selected episode to watch later / saved episodes | +| `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes | +| `f` | Cycle All / Unwatched / Watched in watch later | +| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection | +| `d` | Focus/unfocus the synopsis/metadata pane; `j` / `k` and page keys scroll while focused | +| Esc | Cancel input/help or return focus to the episode/title list | +| `r` | Refresh the active screen and reload configured keys | +| `?` / `q` | Keyboard help / quit | + +These actions reuse the existing `[keys]` configuration; live hints and help omit unsupported YouTube actions. Only episodes—not catalog titles—can be saved. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and acknowledged watched status; Terebi owns resume positions. Stream and external VTT subtitle headers are supplied explicitly; Terebi selects the source's declared default subtitle. Sources requiring media redirects, cross-origin HLS, proxies, iframe playback, or provider-specific playlist rewriting fail explicitly. No provider/language fallback or automatic expired-source refresh is performed. -## Controls +## YouTube controls These are the default bindings. Overrides in `[keys]` replace an action's bindings; the live hints and `?` reference show the configured keys. @@ -70,9 +87,9 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | Key | Action | | --- | --- | | `j` / `k`, ↓ / ↑ | Select the next / previous video | -| Enter | Open a Miruro catalog title's episodes, or launch the selected video/episode in Terebi | +| Enter | Launch the selected video in Terebi | | `g` / Home, `G` / End | Select the first / last loaded video | -| `i` | Edit the current category/combined feed or Miruro search; other views use general YouTube Search | +| `i` | Edit the current category/combined feed search; other views use general YouTube Search | | `/` | 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 | @@ -217,7 +234,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**, **Feed**, and **Miruro** are permanent tabs. Feed combines all YouTube `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates. +- **Search** and **Feed** are permanent YouTube tabs. Feed combines all YouTube `[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. @@ -226,9 +243,9 @@ Bulk editing refuses to clear all tags when a target has no `[channels]` registr ## Watch later -Watch later is **one collection**, deduplicated by YouTube video ID or stable Miruro episode identity. Category lists are derived from saved YouTube 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`. Miruro episodes appear only in the combined list. No category memberships are stored on individual videos. +Watch later is **one stored collection**, deduplicated by YouTube video ID or stable Miruro episode identity, with provider-specific views. In YouTube, category lists are derived from 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`. Its combined list contains only YouTube videos. Miruro uses the flat episode lists described above. 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. +In YouTube, `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. @@ -254,9 +271,9 @@ Progress sessions survive Tubular exit so later player reports can be recovered ## Saved videos -Saved videos is a separate collection from watch later, deduplicated by YouTube video ID or stable Miruro episode identity. It uses the same offline title, creator, and publication metadata; YouTube entries follow **current channel category memberships**, not categories stored on individual videos. Miruro episodes appear in All categories (combined). +Saved videos is a separate stored collection from watch later, deduplicated by YouTube video ID or stable Miruro episode identity, with provider-specific views. It uses offline saved metadata; YouTube entries follow **current channel category memberships**, not categories stored on individual videos. Its All categories (combined) list contains only YouTube videos; Miruro opens a flat saved-episode list. -`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. +In YouTube, `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. @@ -266,7 +283,7 @@ The collection is persisted independently in `saved-videos.toml` beside `config. ## 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`. +`C` opens an offline list of the current provider's started but unfinished videos/episodes. YouTube uses its shared temporary tab; Miruro uses a cached episode-list screen. Both support 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. @@ -278,9 +295,9 @@ Use `x` for individual removal or `X` for bulk confirmation. Removal neither mar Removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. Search, Feed, Miruro catalog/episode browsing, 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. +`x` immediately removes only the selected video/episode from the active collection. `X` snapshots the current provider's visible list and opens bulk confirmation: YouTube watch later uses the current category and All/Unwatched/Watched filter, and saved videos uses the current category's entire saved list; Miruro uses its flat list and any active watch-later filter. Currently watching uses the current provider's entire list. An empty list does not open confirmation. Neither interface's removal controls touch the other provider's entries. -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. +The YouTube prompt names the collection, category, count and applicable filter; Miruro names the collection and episode count, with the active filter in the screen header. **`y` confirms; `n` or Esc cancels.** Miruro also accepts Enter to confirm. 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. diff --git a/docs/miruro-api.md b/docs/miruro-api.md index c018f13..eb42fe0 100644 --- a/docs/miruro-api.md +++ b/docs/miruro-api.md @@ -431,7 +431,7 @@ Wrapper-extraction verification, before TUI integration: `cargo test --locked` p ## Tubular → Terebi integration -`tubular` offers a centered YouTube/Miruro startup selector; an initial query follows the chosen provider. `tubular --miruro "apothecary"` bypasses it and starts the permanent Miruro search tab directly. Catalog Enter opens actual returned episodes; episode Enter resolves fresh sources off-thread. Autocomplete is limited to eight titles, without invented pagination. One worker handles bounded network work; changing views/queries invalidates stale responses rather than launching obsolete selections. Canonical title/slug rules follow the [deployed helper][link-helpers], including Unicode NFD normalization. +`tubular` offers a centered YouTube/Miruro startup selector; an initial query follows the chosen provider. `tubular --miruro "apothecary"` bypasses it and starts the dedicated Miruro interface directly. `src/miruro_ui.rs` owns catalog → series → episodes navigation, synopsis/metadata presentation, cached screen history and flat episode collections; YouTube retains its separate Search/Feed/category interface. Catalog Enter opens actual returned episodes; episode Enter resolves fresh sources off-thread. Autocomplete is limited to eight titles, without invented pagination. `src/miruro_browser.rs` supplies native catalog/series/episode models and one worker for bounded network work; changing views/queries or episode selections invalidates stale source responses rather than launching obsolete selections. Canonical title/slug rules follow the [deployed helper][link-helpers], including Unicode NFD normalization. Titles normalizing to an empty slug fail explicitly; the wrapper does not guess an alternative detail route. This does not reject accented Latin titles, which use NFD decomposition. @@ -474,7 +474,7 @@ Tubular validates the version, namespace, media ID, episode, event and numeric f Matching acknowledged positive positions admit currently-watching entries. Watched status retains `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`; null duration is ineligible. Seeking near the end can satisfy it. Launch/window closure alone cannot. Duplicate records, partial trailing lines and later reports recovered after Tubular restart retain the existing session-consumption behavior. -Watch later, saved videos, currently watching and watched state accept stable episode keys `miruro::`. Catalog rows (`miruro-series:`) are not saveable/playable episode identities. Saved episode playback resolves fresh sources; CDN addresses/tokens never become collection keys. Miruro has no YouTube channel identity, so category/tag/channel actions and YouTube extraction/thumbnails are excluded; its episodes use combined collections. +Watch later, saved videos, currently watching and watched state accept stable episode keys `miruro::`. Catalog results use opaque media IDs in native `Media` rows, not fabricated video/episode keys; they are not saveable/playable episode identities. Saved episode playback resolves fresh sources; CDN addresses/tokens never become collection keys. Playback and persisted state remain shared, but each interface filters collections before display, selection, counts and removal. Miruro has no YouTube channel identity, so its flat episode lists exclude category/tag/channel actions and YouTube extraction/thumbnails. Planned/listed counts are metadata, not a claim of supported source availability; only actual returned episode numbers are offered for playback. ### Integration verification @@ -484,11 +484,21 @@ The private source document retained Origin/Referer headers and nine VTT tracks After Tubular exited successfully, its independent GUI continued appending matching v2 progress and the private source file remained byte-for-byte unchanged. That player/session was intentionally retained for the user's visible subtitle/seek check; no source cleanup was attempted while playback remained active. -Final integrated checks passed: `cargo fmt --check`, all **175 tests**, `cargo clippy --locked --all-targets -- -D warnings`, `cargo build --locked`, and the built executable's `--help`. Regressions cover v1 compatibility, v2 identity/95%/null-duration boundaries, restart/partial-report recovery, source/session permissions and retention, collection routing, stale responses and canonical Unicode slugging. The installed Terebi CLI was older and lacked `--source-file`; the explicit developer-bundle override avoided changing that installation. +Initial integrated checks passed: `cargo fmt --check`, all **175 tests**, `cargo clippy --locked --all-targets -- -D warnings`, `cargo build --locked`, and the built executable's `--help`. Regressions cover v1 compatibility, v2 identity/95%/null-duration boundaries, restart/partial-report recovery, source/session permissions and retention, collection routing, stale responses and canonical Unicode slugging. The installed Terebi CLI was older and lacked `--source-file`; the explicit developer-bundle override avoided changing that installation. Startup provider-selection verification: **176 tests**, formatting, Clippy and build passed. Actual terminal runs showed the centered selector, routed both provider choices into their existing interfaces, carried an initial query into Miruro autocomplete, honored remapped selector keys, canceled on an encoded terminal Escape event, and preserved direct `--miruro` startup and noninteractive help/default-config. Bare Escape-byte injection in the automated terminal remained ambiguous; the complete terminal key encoding was exercised instead. -This run proves Tubular-to-Terebi launch, acknowledged playback/progress consumption and independent lifetime—not visible subtitle rendering, perceived A/V synchronization, or seeks through the user's installed release. English subtitle appearance/synchronization before and after seeking still requires a visible-app check. +Those initial integration runs prove Tubular-to-Terebi launch, acknowledged playback/progress consumption and independent lifetime—not visible subtitle rendering, perceived A/V synchronization, or seeks through the user's installed release. English subtitle appearance/synchronization before and after seeking still requires a visible-app check. + +### Dedicated-interface verification + +The cutover passed **181 tests**, formatting, Clippy with warnings denied and build. Regressions cover cached catalog/series navigation, real episode identities, stale source rejection, provider-scoped collections and filtered removal, matching v2 completion, validated private source sessions, sanitized input/errors and configured controls. + +Actual terminal runs exercised both chooser routes and direct `--miruro` startup. Miruro showed eight catalog titles with anime metadata, two returned episodes rather than the twelve planned episodes, selected-episode synopsis/date/duration above series metadata, cached back/forward selection and dedicated keyboard help. YouTube retained Search/Feed and its existing video/collection controls. Mixed-provider fixtures showed only the selected provider's entries; removing two Miruro saved episodes left the YouTube sentinel and both Miruro watch-later entries intact, including after restarting into YouTube. Configured F8/F9 controls were exercised; replaced default quit/interrupt keys no longer forced an exit. + +A freshly resolved episode-2 source retained nine VTT tracks with English default, explicit stream/subtitle header names and the expected opaque episode identity. Terebi's existing `resolved-playback-check` decoded video/audio, advanced playback and passed default-VTT/forward/backward acknowledged cue checks. It also emitted three redacted FFmpeg error lines; their cause was not established. Session/source/report modes were `0700`/`0600`/`0600`; the source hash remained unchanged after Tubular exited. + +The independent GUI accepted that launch and opened its private report, but the report remained empty: no GUI ready state, advancing GUI progress or current-watching admission is claimed for this run. The visible window needs a human check for a startup prompt or playback error, plus subtitle/seek synchronization. The native diagnostic's pass is not a GUI sign-off. Its active private session was retained rather than deleted. ## Remaining research questions diff --git a/src/main.rs b/src/main.rs index c962df3..1dd8599 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -//! A Ratatui YouTube/Miruro browser with playback handed to Terebi. +//! Independent Ratatui YouTube and Miruro browsers with playback handed to Terebi. //! //! Run `cargo run -- "rust programming"`; default to the installed Terebi CLI on PATH. //! Key instructions below describe defaults. `[keys]` configures all actions; live hints use overrides. //! `--default-config` prints complete TOML without loading config or starting the terminal. //! Startup offers a centered YouTube/Miruro chooser; --miruro starts directly. Browsers start in normal mode. //! A CLI query searches the selected provider after confirmation. Press i to edit. -//! `--miruro apothecary` starts the persistent Miruro tab; Enter drills into actual returned episodes. +//! `--miruro apothecary` opens the dedicated Catalog → series → episodes interface. //! Miruro autocomplete returns at most eight catalog titles, without pagination; catalog titles cannot be saved. //! Playback resolves fresh direct ssub/sun sources off-thread and passes a private source file to Terebi. //! Miruro episode collections retain stable catalog/episode keys, never stream URLs; channel tags remain YouTube-only. @@ -58,7 +58,7 @@ //! Unchecked tags lose only target channels; other creators, registrations, pins, and empty categories remain. //! n stages a new shared tag; Esc cancels the whole edit. Changed source membership requires reopening. //! Clearing all tags is refused for category-only channels, avoiding accidental unsubscription. -//! Search, Feed, and Miruro are permanent tabs. Normal-mode c opens the saved-category browser. +//! YouTube Search and Feed are permanent tabs. Normal-mode c opens the saved-category browser. //! The category browser starts NORMAL: j/k or arrows move, i filters, Space checks, Enter browses, p pins. //! INSERT filters live with case-insensitive substring matching; arrows or Ctrl-J/K move, Enter browses. //! Esc returns to NORMAL without clearing the filter; another Esc closes. Search input editing shortcuts apply. @@ -120,6 +120,7 @@ mod config; mod keys; mod miruro_browser; +mod miruro_ui; mod thumbnail; mod video_state; mod youtube; @@ -156,7 +157,7 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube or Miruro 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.\nYouTube browsing requires yt-dlp; YouTube thumbnails also use curl. Miruro uses the native client and requires Terebi --source-file support. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nMiruro Enter opens catalog episodes, then resolves fresh direct ssub/sun HLS/VTT into a private source document; the player receives --new-instance --source-file PATH --progress-file PATH. Episodes use stable catalog/episode identity for collections and progress; channel tags remain YouTube-only.\nFor YouTube, Terebi 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.\nStartup shows a centered YouTube/Miruro provider chooser using configured selector keys; Esc/q quits. --miruro skips the chooser. The chosen browser starts in NORMAL and searches any CLI query 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 the trimmed query if changed and returns to NORMAL; Esc keeps the draft without submitting. In category and combined-category views, queries search only those channels' full histories, not just loaded rows; clearing the query restores uploads. Each category keeps its own draft, submitted query and pagination, independent of general Search. Miruro edits its own catalog search; other views still submit to general YouTube Search. Repeating a submitted query reuses its cache or pending request.\nTEXT EDITING (search, category/channel filters, new tag, and category name prompts): 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; / jumps to Search; F jumps to Feed; 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; R renames the current explicit category; P saves the active combined category feed as a new category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens a searchable watch-later collection selector; Alt-w opens the current explicit category's watch-later list. Both work in previews. Moving between watch-later scopes preserves the watched filter. Lists follow current channel category memberships and use persisted metadata offline. Only in watch-later views and their previews, x removes the selected video and X (Shift-X) prompts to remove the current filtered/category subset. Configure view.collection.remove.selected and view.collection.remove.bulk. In watch-later views f cycles All/Unwatched/Watched; y confirms bulk, n/Esc cancels, and Enter never confirms. The other collection and watched status are unchanged. 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 a searchable saved-video collection selector; Alt-v opens the current explicit category's saved-video list, including from a category watch-later list. Both work in previews. Alt-w/Alt-v require a category view or category-scoped collection; Search, Feed, combined category feeds, channel views and all-categories collections do not infer a category. Lists derive from current channel memberships and use offline metadata in saved-videos.toml beside config.toml. Only in saved-video views and their previews, x removes the selected video and X prompts to remove the current category/list subset, without changing watch later or watched state. Search, Feed, category and channel views and their previews never remove collection membership. Saved titles are green, taking precedence over watch-later magenta. Saved lists have no watched filter; bulk removal snapshots the current list. Both collections share the temporary tab and cached back/forward history.\nCURRENTLY WATCHING: C (Shift-C) directly opens the combined offline list of acknowledged started, unfinished videos. Positive playback progress admits a video, not launcher success; watched videos leave automatically. x removes selected and X confirms removing the list, without marking watched. Removal persists and old session reports never re-add it; a new playback session can. currently-watching.toml sits beside config.toml. This view shares collection previews, temporary tabs and navigation history; it has no category selector or watched filter.\nCOLLECTION SELECTORS (W / V): All categories (combined) is the first entry, followed by configured categories. Start in NORMAL; j/k or arrows move; i enters INSERT to filter by a case-insensitive substring. Arrows or Ctrl-J/K move while typing; Enter opens the selected scope, or does nothing with no matches. Esc returns to NORMAL then closes without navigating; reopening clears the filter. Collection selectors do not change category pins.\nSearch, Feed and Miruro 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; Space checks categories; Enter browses checked categories, or the hovered match if none are checked; p immediately saves pin/unpin; R renames the hovered category; Esc cancels checks and closes. Checkboxes are separate from the dim p pin marker. One checked category opens its ordinary view; multiple checked categories open their combined newest-first, deduplicated feed. Configure selector.toggle to change Space. 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 checked categories, or the selected match if none are checked; Esc returns to NORMAL with the filter retained. Space remains text while filtering. Hidden checked categories stay checked. With no checks and no matches, Enter/p do nothing. Reopening clears the filter and prechecks the active combined feed members.\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, combined category feeds 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, combined category and channel views retain loaded rows, selection, and pagination for this session. Combined feeds retain their cache when member assignments change but the resolved channel union stays equal; deleting a member invalidates the combined view rather than widening it. 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 60 results per page; Feed, categories, and channel views share a 60-row budget across up to four concurrent extractors. Category queries request matching channel-history results instead of uploads.\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 sort all loaded rows newest-first using approximate upload dates, including undated rows. Selection follows video identity when later pages reorder rows.\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; R renames the selected tag; Esc cancels staged memberships without undoing committed renames.\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.\nSAVE COMBINED CATEGORY (P / Shift-P): only an active combined category feed can be saved, including from its previews. The floating name prompt uses shared input editing controls; Enter saves a new flat category containing the currently viewed channel union, using mapped handles where available; Esc cancels without writes. Invalid or duplicate names keep the prompt open and current view unchanged. Saving copies memberships, not dynamic source-category definitions; later source-category edits do not affect the saved category. The new category becomes the active unpinned temporary tab, preserving matching cached rows, selection, pending pages, query draft and submitted query, and pagination without refetching. Source categories and existing pins stay unchanged. Configure category.save; P remains text in inputs.\nCATEGORY RENAME (R / Shift-R): rename the current category or category-scoped collection, the hovered category in category/collection selectors, or a selected tag in single/bulk tagging. Combined and channel rows cannot be renamed. The prefilled floating input uses shared editing controls; Enter commits, Esc cancels. Existing category identity is saved immediately, with memberships, pins, comments, cached selection/pages/watch filters and navigation history preserved. Tag membership changes remain staged until the main tag-list Enter; an unstored new tag rename also stays staged. Invalid, duplicate, or externally changed source categories keep the prompt open without writes; close dialogs and reload config with r before retrying external changes. Configure category.rename; R remains literal in filter/search inputs.\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.\nYouTube browsing requires yt-dlp; YouTube thumbnails also use curl. Miruro uses the native client and requires Terebi --source-file support. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nMiruro Enter opens catalog episodes, then resolves fresh direct ssub/sun HLS/VTT into a private source document; the player receives --new-instance --source-file PATH --progress-file PATH. Episodes use stable catalog/episode identity for collections and progress; channel tags remain YouTube-only.\nFor YouTube, Terebi 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.\nStartup selects independent YouTube or Miruro interfaces through a centered provider chooser using configured selector keys; Esc/q quits. --miruro skips the chooser. The chosen browser starts in NORMAL and searches any CLI query 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 the trimmed query if changed and returns to NORMAL; Esc keeps the draft without submitting. In category and combined-category views, queries search only those channels' full histories, not just loaded rows; clearing the query restores uploads. Each category keeps its own draft, submitted query and pagination, independent of general Search. In the independent Miruro interface, input searches its catalog; YouTube views submit to general YouTube Search. Repeating a submitted query reuses its cache or pending request.\nTEXT EDITING (search, category/channel filters, new tag, and category name prompts): 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.\nYOUTUBE NORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; / jumps to Search; F jumps to Feed; 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; R renames the current explicit category; P saves the active combined category feed as a new category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens a searchable watch-later collection selector; Alt-w opens the current explicit category's watch-later list. Both work in previews. Moving between watch-later scopes preserves the watched filter. Lists follow current channel category memberships and use persisted metadata offline. Only in watch-later views and their previews, x removes the selected video and X (Shift-X) prompts to remove the current filtered/category subset. Configure view.collection.remove.selected and view.collection.remove.bulk. In watch-later views f cycles All/Unwatched/Watched; y confirms bulk, n/Esc cancels, and Enter never confirms. The other collection and watched status are unchanged. 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 a searchable saved-video collection selector; Alt-v opens the current explicit category's saved-video list, including from a category watch-later list. Both work in previews. Alt-w/Alt-v require a category view or category-scoped collection; Search, Feed, combined category feeds, channel views and all-categories collections do not infer a category. Lists derive from current channel memberships and use offline metadata in saved-videos.toml beside config.toml. Only in saved-video views and their previews, x removes the selected video and X prompts to remove the current category/list subset, without changing watch later or watched state. Search, Feed, category and channel views and their previews never remove collection membership. Saved titles are green, taking precedence over watch-later magenta. Saved lists have no watched filter; bulk removal snapshots the current list. Both collections share the temporary tab and cached back/forward history.\nCURRENTLY WATCHING: C (Shift-C) directly opens the provider-local combined offline list of acknowledged started, unfinished videos. Positive playback progress admits a video, not launcher success; watched videos leave automatically. x removes selected and X confirms removing the list, without marking watched. Removal persists and old session reports never re-add it; a new playback session can. currently-watching.toml sits beside config.toml. This view shares collection previews, temporary tabs and navigation history; it has no category selector or watched filter.\nCOLLECTION SELECTORS (W / V): All categories (combined) is the first entry, followed by configured categories. Start in NORMAL; j/k or arrows move; i enters INSERT to filter by a case-insensitive substring. Arrows or Ctrl-J/K move while typing; Enter opens the selected scope, or does nothing with no matches. Esc returns to NORMAL then closes without navigating; reopening clears the filter. Collection selectors do not change category pins.\nYouTube Search 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; Space checks categories; Enter browses checked categories, or the hovered match if none are checked; p immediately saves pin/unpin; R renames the hovered category; Esc cancels checks and closes. Checkboxes are separate from the dim p pin marker. One checked category opens its ordinary view; multiple checked categories open their combined newest-first, deduplicated feed. Configure selector.toggle to change Space. 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 checked categories, or the selected match if none are checked; Esc returns to NORMAL with the filter retained. Space remains text while filtering. Hidden checked categories stay checked. With no checks and no matches, Enter/p do nothing. Reopening clears the filter and prechecks the active combined feed members.\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, combined category feeds 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, combined category and channel views retain loaded rows, selection, and pagination for this session. Combined feeds retain their cache when member assignments change but the resolved channel union stays equal; deleting a member invalidates the combined view rather than widening it. 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 60 results per page; Feed, categories, and channel views share a 60-row budget across up to four concurrent extractors. Category queries request matching channel-history results instead of uploads.\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 sort all loaded rows newest-first using approximate upload dates, including undated rows. Selection follows video identity when later pages reorder rows.\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; R renames the selected tag; Esc cancels staged memberships without undoing committed renames.\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.\nSAVE COMBINED CATEGORY (P / Shift-P): only an active combined category feed can be saved, including from its previews. The floating name prompt uses shared input editing controls; Enter saves a new flat category containing the currently viewed channel union, using mapped handles where available; Esc cancels without writes. Invalid or duplicate names keep the prompt open and current view unchanged. Saving copies memberships, not dynamic source-category definitions; later source-category edits do not affect the saved category. The new category becomes the active unpinned temporary tab, preserving matching cached rows, selection, pending pages, query draft and submitted query, and pagination without refetching. Source categories and existing pins stay unchanged. Configure category.save; P remains text in inputs.\nCATEGORY RENAME (R / Shift-R): rename the current category or category-scoped collection, the hovered category in category/collection selectors, or a selected tag in single/bulk tagging. Combined and channel rows cannot be renamed. The prefilled floating input uses shared editing controls; Enter commits, Esc cancels. Existing category identity is saved immediately, with memberships, pins, comments, cached selection/pages/watch filters and navigation history preserved. Tag membership changes remain staged until the main tag-list Enter; an unstored new tag rename also stays staged. Invalid, duplicate, or externally changed source categories keep the prompt open without writes; close dialogs and reload config with r before retrying external changes. Configure category.rename; R remains literal in filter/search inputs.\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; Miruro also requires --source-file support. @@ -167,7 +168,7 @@ struct Options { #[arg(long, conflicts_with = "query")] default_config: bool, - /// Skip the provider chooser and start in Miruro (autocomplete, eight results). + /// Skip the provider chooser and open the dedicated Miruro catalog interface. #[arg(long)] miruro: bool, @@ -197,8 +198,6 @@ enum Mode { enum ViewSource { Search, Feed, - MiruroSearch, - MiruroEpisodes { media_id: String, slug: String }, Category(String), CategoryGroup(Vec), Channel(String), @@ -215,12 +214,7 @@ impl ViewSource { group: &'a [String], ) -> &'a [String] { match self { - Self::Search - | Self::MiruroSearch - | Self::MiruroEpisodes { .. } - | Self::WatchLater(_) - | Self::SavedVideos(_) - | Self::Watching => &[], + Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) | Self::Watching => &[], Self::Feed => feed, Self::Category(name) => &config.categories[name], Self::CategoryGroup(_) => group, @@ -945,109 +939,10 @@ struct App { description_viewport: Option<(u16, u16)>, job: Option, player_launch: Option, - miruro_worker: Option, - miruro_generation: u64, - miruro_pending: Option, - miruro_loading: bool, status: String, } impl App { - fn cancel_miruro(&mut self) { - self.miruro_generation = self.miruro_generation.wrapping_add(1); - self.miruro_pending = None; - self.miruro_loading = false; - } - - fn request_miruro(&mut self, request: miruro_browser::Request) { - self.cancel_miruro(); - self.miruro_pending = Some(request); - self.miruro_loading = true; - self.status = "Loading Miruro (direct ssub/sun only)...".into(); - } - - fn accept_miruro(&mut self, generation: u64, result: io::Result) { - if generation != self.miruro_generation { - return; - } - self.miruro_loading = false; - match result { - Ok(miruro_browser::Response::Rows(rows)) => { - let selected = self.views[self.view_index] - .selection - .selected() - .and_then(|index| self.views[self.view_index].videos.get(index)) - .map(|video| video.id.clone()); - let mut videos = Vec::with_capacity(rows.len()); - for (video, description) in rows { - self.remember_description(video.id.clone(), description); - videos.push(video); - } - let view = &mut self.views[self.view_index]; - view.selection.select( - selected - .and_then(|id| videos.iter().position(|video| video.id == id)) - .or_else(|| (!videos.is_empty()).then_some(0)), - ); - view.videos = videos; - view.loaded = true; - view.has_more = false; - view.page_error = None; - self.status = self.view_status(); - } - Ok(miruro_browser::Response::Source(video, source)) => { - self.player_launch = None; - match self.video_state.miruro_session(&video, &source).and_then( - |(source_file, progress_file)| { - PlayerLaunch::start_source( - &source_file, - &self.options.player, - &progress_file, - ) - }, - ) { - Ok(launch) => { - self.player_launch = Some(launch); - self.status = format!( - "Opening {} in {}...", - video.title, - self.options.player.to_string_lossy() - ); - } - Err(error) => self.status = error.to_string(), - } - } - Err(error) => { - self.views[self.view_index].page_error = Some(error.to_string()); - self.status = error.to_string(); - } - } - } - - fn update_miruro(&mut self) { - if let Some((generation, result)) = self - .miruro_worker - .as_mut() - .and_then(miruro_browser::Worker::poll) - { - self.accept_miruro(generation, result); - } - if self.miruro_pending.is_some() - && !self - .miruro_worker - .as_ref() - .is_some_and(|worker| worker.busy) - { - let worker = self - .miruro_worker - .get_or_insert_with(miruro_browser::Worker::new); - let request = self.miruro_pending.take().expect("pending request exists"); - if let Err(error) = worker.submit(self.miruro_generation, request) { - self.miruro_loading = false; - self.status = error.to_string(); - } - } - } #[cfg(test)] fn new(options: Options, config_path: PathBuf, config: config::Config) -> Self { static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -1088,8 +983,7 @@ impl App { .keys() .map(|name| View::new(name.clone(), ViewSource::Category(name.clone()))), ); - let initial_index = if options.miruro { views.len() } else { 0 }; - views.push(View::new("Miruro".into(), ViewSource::MiruroSearch)); + let initial_index = 0; views[initial_index].query = options.query.take().unwrap_or_default(); Self { options, @@ -1120,16 +1014,7 @@ impl App { description_viewport: None, job: None, player_launch: None, - miruro_worker: None, - miruro_generation: 0, - miruro_pending: None, - miruro_loading: false, - status: if initial_index == 0 { - "Enter a query to search YouTube." - } else { - "Enter a query to search Miruro." - } - .into(), + status: "Enter a query to search YouTube.".into(), } } @@ -1138,9 +1023,9 @@ impl App { .iter() .enumerate() .filter(|(_, view)| match &view.source { - ViewSource::Search | ViewSource::Feed | ViewSource::MiruroSearch => true, + ViewSource::Search | ViewSource::Feed => true, ViewSource::Category(name) => self.config.pinned_categories.contains(name), - ViewSource::MiruroEpisodes { .. } | ViewSource::CategoryGroup(_) | ViewSource::Channel(_) + ViewSource::CategoryGroup(_) | ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching => false, }) .chain(self.temporary_view.as_ref().and_then(|source| { @@ -1396,10 +1281,6 @@ impl App { self.status = "Select a video first.".into(); return; }; - if miruro_browser::is_miruro(video) { - self.status = "Miruro episodes do not have YouTube channels.".into(); - return; - } if matches!(view.source, ViewSource::Channel(_)) { self.switch_view(self.view_index); return; @@ -1455,7 +1336,7 @@ impl App { fn query_view_index(&self) -> usize { if matches!( self.views[self.view_index].source, - ViewSource::Category(_) | ViewSource::CategoryGroup(_) | ViewSource::MiruroSearch + ViewSource::Category(_) | ViewSource::CategoryGroup(_) ) { self.view_index } else { @@ -1467,14 +1348,6 @@ impl App { let index = self.query_view_index(); let view = &mut self.views[index]; let query = view.query.trim(); - if matches!(view.source, ViewSource::MiruroSearch) && query.is_empty() { - self.cancel_miruro(); - self.views[index].submitted_query.clear(); - self.views[index].reset(); - self.views[index].loaded = true; - self.status = "Enter a query to search Miruro.".into(); - return; - } if index == 0 && query.is_empty() { self.status = "Enter a query to search YouTube.".into(); return; @@ -1483,7 +1356,6 @@ impl App { view.submitted_query = query.to_owned(); view.reset(); self.job = None; - self.cancel_miruro(); } self.switch_view(index); } @@ -1528,7 +1400,6 @@ impl App { if matches!( source, ViewSource::CategoryGroup(_) - | ViewSource::MiruroEpisodes { .. } | ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) @@ -1539,7 +1410,6 @@ impl App { } if index != self.view_index { self.job = None; - self.cancel_miruro(); self.view_index = index; } self.close_preview(); @@ -1554,7 +1424,6 @@ impl App { fn load_view(&mut self) { self.job = None; - self.cancel_miruro(); self.close_preview(); if matches!( self.views[self.view_index].source, @@ -1569,7 +1438,7 @@ impl App { } fn load_page(&mut self) { - if self.job.is_some() || self.miruro_loading { + if self.job.is_some() { return; } if matches!( @@ -1580,22 +1449,6 @@ impl App { self.status = self.view_status(); return; } - match self.views[self.view_index].source.clone() { - ViewSource::MiruroSearch => { - let query = self.views[self.view_index].submitted_query.clone(); - if query.is_empty() { - self.status = "Enter a query to search Miruro.".into(); - } else { - self.request_miruro(miruro_browser::Request::Search(query)); - } - return; - } - ViewSource::MiruroEpisodes { media_id, slug } => { - self.request_miruro(miruro_browser::Request::Episodes { media_id, slug }); - return; - } - _ => {} - } let view = &mut self.views[self.view_index]; let page = (view.next_start - 1) / PAGE_SIZE + 1; let result = if view.source == ViewSource::Search { @@ -1673,21 +1526,6 @@ impl App { (view.next_start - 1) / PAGE_SIZE + 1 ); } - if matches!( - view.source, - ViewSource::MiruroSearch | ViewSource::MiruroEpisodes { .. } - ) { - return format!( - "{} results in {} · Enter opens {}. Miruro uses direct ssub/sun only.", - view.videos.len(), - view.name, - if matches!(view.source, ViewSource::MiruroSearch) { - "episodes" - } else { - "Terebi" - } - ); - } if view.source != ViewSource::Search && view .source @@ -1765,10 +1603,11 @@ impl App { .videos(kind) .iter() .filter(|video| { - (!matches!(kind, CollectionKind::WatchLater) - || view - .watch_filter - .includes(self.video_state.is_watched(&video.id))) + youtube::valid_video_id(&video.id) + && (!matches!(kind, CollectionKind::WatchLater) + || 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) @@ -1847,10 +1686,6 @@ impl App { self.status = "Select a video first.".into(); return; }; - if video.id.starts_with("miruro-series:") { - self.status = "Select an episode first; catalog titles cannot be saved.".into(); - return; - } let already_saved = self.video_state.contains(kind, &video.id); match self.video_state.save(kind, video) { Ok(()) => { @@ -1993,10 +1828,6 @@ impl App { } self.views.push(feed); let mut old: Vec<_> = old.collect(); - let miruro = old - .iter() - .position(|view| view.source == ViewSource::MiruroSearch) - .map(|index| old.remove(index)); // ponytail: category lookup is quadratic; use a keyed map if hundreds of categories matter. for name in config.categories.keys() { let mut view = if let Some(index) = old.iter().position( @@ -2012,9 +1843,6 @@ impl App { } self.views.push(view); } - if let Some(view) = miruro { - self.views.push(view); - } let saved_channels = config.saved_channels(); for (label, identity) in &saved_channels { if let Some(index) = old.iter().position( @@ -2041,7 +1869,6 @@ impl App { } (matches!(&view.source, ViewSource::Channel(identity) if !self.feed_channels.contains(identity)) || matches!(&view.source, ViewSource::Watching) - || matches!(&view.source, ViewSource::MiruroEpisodes { .. }) || matches!(&view.source, ViewSource::WatchLater(category) | ViewSource::SavedVideos(category) if category.as_ref().is_none_or(|name| config.categories.contains_key(name)))) .then_some(view) })); @@ -2077,7 +1904,6 @@ impl App { fn refresh(&mut self) { self.close_preview(); - self.cancel_miruro(); match config::load(&self.config_path) { Ok(config) => { self.thumbnail.clear_cache(); @@ -2349,8 +2175,6 @@ impl App { matches!( view.source, ViewSource::CategoryGroup(_) - | ViewSource::MiruroSearch - | ViewSource::MiruroEpisodes { .. } | ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) @@ -2378,12 +2202,6 @@ impl App { self.status = "Select a video first.".into(); return; }; - if miruro_browser::is_miruro(video) { - self.status = - "Channel tags are YouTube-only; Miruro episodes can use combined collections." - .into(); - return; - } let Some(id) = video.channel_id.as_deref() else { self.status = "This video has no available channel identity.".into(); return; @@ -2650,10 +2468,6 @@ impl App { self.descriptions.push_back(hit); return; } - if miruro_browser::is_miruro(video) { - self.remember_description(video.id.clone(), "Miruro · saved catalog metadata. Fresh direct ssub/sun sources are resolved only on playback.".into()); - return; - } match Job::description(&video.id) { Ok(job) => self.description_job = Some((video.id.clone(), job)), Err(error) => self.description_error = Some(error.to_string()), @@ -2683,30 +2497,6 @@ impl App { else { return; }; - if let Some(media_id) = video.id.strip_prefix("miruro-series:") { - let slug = match miruro_browser::slug(&video.title) { - Ok(slug) => slug, - Err(error) => { - self.status = error.to_string(); - return; - } - }; - self.browse_view( - video.title.clone(), - ViewSource::MiruroEpisodes { - media_id: media_id.to_owned(), - slug, - }, - ); - return; - } - if video.id.starts_with("miruro:") { - match tubular::miruro::Identity::from_key(&video.id) { - Ok(_) => self.request_miruro(miruro_browser::Request::Source(video.clone())), - Err(error) => self.status = error.to_string(), - } - return; - } self.player_launch = None; let progress_file = match self.video_state.progress_file(video) { Ok(path) => path, @@ -2737,7 +2527,6 @@ impl App { } fn update(&mut self) { - self.update_miruro(); if let Some(result) = self.job.as_mut().and_then(Job::poll) { self.finish_page(result); } @@ -3080,11 +2869,7 @@ impl App { .selected() .and_then(|index| self.views[self.view_index].videos.get(index)) { - if !miruro_browser::is_miruro(video) { - self.thumbnail.select(&video.id); - } else { - self.thumbnail.cancel(); - } + self.thumbnail.select(&video.id); } false } @@ -3148,7 +2933,6 @@ impl App { let query = ratatui::text::Line::raw(query_view.query.as_str()); let query_width = query.width(); let search_title: Line<'_> = match query_view.source { - ViewSource::MiruroSearch => " tubular / Miruro search (8 results) ".into(), ViewSource::Category(_) | ViewSource::CategoryGroup(_) => { " tubular / Category search ".into() } @@ -3229,7 +3013,7 @@ impl App { ViewSource::CategoryGroup(_) => self.key_hints.group_save.as_str(), _ => "", }, - (self.job.is_some() || self.miruro_loading).then_some(spinner), + self.job.is_some().then_some(spinner), ))) .row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)) .highlight_symbol("> "); @@ -3965,7 +3749,11 @@ fn main() -> io::Result<()> { options.miruro = miruro; } let state = video_state::State::open(&config_path)?; - App::with_state(options, config_path, config, state).run(terminal) + if options.miruro { + miruro_ui::run(terminal, options, config_path, config, state) + } else { + App::with_state(options, config_path, config, state).run(terminal) + } }) } @@ -3999,16 +3787,13 @@ mod tests { assert_eq!(choice, miruro); let mut options = Options::parse_from(["tubular", "initial query"]); options.miruro = choice; - let app = App::new(options, PathBuf::new(), config::Config::default()); - assert_eq!(app.views[app.view_index].query, "initial query"); - assert_eq!( - app.views[app.view_index].source, - if miruro { - ViewSource::MiruroSearch - } else { - ViewSource::Search - } - ); + assert_eq!(options.query.as_deref(), Some("initial query")); + assert_eq!(options.miruro, miruro); + if !miruro { + let app = App::new(options, PathBuf::new(), config::Config::default()); + assert_eq!(app.views[0].query, "initial query"); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + } } for action in [Action::SelectorClose, Action::Quit, Action::Interrupt] { assert_eq!(provider_selection(&mut selected, Some(action)), Break(None)); @@ -4016,112 +3801,6 @@ mod tests { assert_eq!(provider_selection(&mut selected, None), Continue(())); } - #[test] - fn miruro_drilldown_collections_reopen_and_stale_results() { - let mut app = app(&["tubular", "--miruro", "apothecary"]); - assert_eq!(app.views[app.view_index].source, ViewSource::MiruroSearch); - assert_eq!(app.views[app.view_index].query, "apothecary"); - let media = serde_json::json!({"id":"opaque_ID","title":{"english":"Apothecary"}}); - let rows = miruro_browser::catalog_rows(vec![media.clone()]).unwrap(); - app.accept_miruro( - app.miruro_generation, - Ok(miruro_browser::Response::Rows(rows)), - ); - app.save_collection(CollectionKind::Saved); - assert!( - !app.video_state - .contains(CollectionKind::Saved, "miruro-series:opaque_ID") - ); - assert!(app.status.contains("cannot be saved")); - app.play(); - assert_eq!( - app.views[app.view_index].source, - ViewSource::MiruroEpisodes { - media_id: "opaque_ID".into(), - slug: "apothecary".into(), - } - ); - assert!(matches!( - app.miruro_pending, - Some(miruro_browser::Request::Episodes { .. }) - )); - let episodes = || { - miruro_browser::episode_rows("opaque_ID", tubular::miruro::Detail { - media: media.clone(), - episodes: vec![ - serde_json::json!({"episode_number":7,"absolute_number":49,"title":"Seven","synopsis":"Catalog synopsis"}), - serde_json::json!({"episode_number":2,"title":"Two"}), - ], - }).unwrap() - }; - app.accept_miruro( - app.miruro_generation, - Ok(miruro_browser::Response::Rows(episodes())), - ); - app.miruro_pending = None; - app.views[app.view_index].selection.select(Some(0)); - app.save_collection(CollectionKind::Saved); - app.save_collection(CollectionKind::WatchLater); - assert!( - app.video_state - .contains(CollectionKind::Saved, "miruro:opaque_ID:7") - ); - assert!( - app.video_state - .contains(CollectionKind::WatchLater, "miruro:opaque_ID:7") - ); - let mut reordered = episodes(); - reordered.reverse(); - app.accept_miruro( - app.miruro_generation, - Ok(miruro_browser::Response::Rows(reordered)), - ); - assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - app.open_category_picker(); - app.open_video_channel(); - app.load_description(); - assert!(app.channel_job.is_none()); - assert!(app.description_job.is_none()); - assert!(app.job.is_none()); - app.browse_collection(CollectionKind::Saved); - app.play(); - assert!( - matches!(&app.miruro_pending, Some(miruro_browser::Request::Source(video)) - if video.id == "miruro:opaque_ID:7" && video.channel_id.is_none()) - ); - let stale = app.miruro_generation; - app.switch_view(0); - app.accept_miruro(stale, Ok(miruro_browser::Response::Rows(episodes()))); - assert!(app.views[0].videos.is_empty()); - assert!(app.miruro_pending.is_none()); - assert!(app.miruro_worker.is_none()); - let miruro_index = app - .views - .iter() - .position(|view| view.source == ViewSource::MiruroSearch) - .unwrap(); - app.switch_view(miruro_index); - app.request_miruro(miruro_browser::Request::Search("old".into())); - let stale = app.miruro_generation; - app.request_miruro(miruro_browser::Request::Search("new".into())); - assert!( - matches!(&app.miruro_pending, Some(miruro_browser::Request::Search(query)) if query == "new") - ); - app.accept_miruro(stale, Err(io::Error::other("stale error"))); - assert!(app.miruro_loading); - app.accept_miruro( - app.miruro_generation, - Err(io::Error::other("Miruro HTTP 403")), - ); - assert_eq!(app.status, "Miruro HTTP 403"); - assert!(!app.miruro_loading); - app.views[miruro_index].query.clear(); - app.search(); - assert!(app.views[miruro_index].videos.is_empty()); - assert!(app.miruro_pending.is_none()); - drop(app); - } - fn app(args: &[&str]) -> App { App::new( Options::parse_from(args), @@ -4130,6 +3809,46 @@ mod tests { ) } + #[test] + fn youtube_collections_isolate_mixed_provider_rows_and_removal_ids() { + let mut app = app(&["tubular"]); + let youtube = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"YouTube","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@creator"}]}"#, + ).unwrap().videos.remove(0); + let mut miruro = youtube.clone(); + miruro.id = "miruro:catalog_ID:1".into(); + miruro.title = "Miruro episode".into(); + miruro.channel = Some("Miruro".into()); + miruro.channel_id = None; + miruro.uploader_id = None; + for kind in [ + CollectionKind::WatchLater, + CollectionKind::Saved, + CollectionKind::Watching, + ] { + app.video_state.save(kind, &youtube).unwrap(); + app.video_state.save(kind, &miruro).unwrap(); + app.browse_collection(kind); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert_eq!(app.views[app.view_index].videos[0].id, youtube.id); + assert_eq!(app.views[app.view_index].selection.selected(), Some(0)); + app.remove_collection_bulk(); + assert_eq!( + app.collection_removal.as_ref().unwrap().ids.as_slice(), + std::slice::from_ref(&youtube.id) + ); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!(!app.video_state.contains(kind, &youtube.id)); + assert!(app.video_state.contains(kind, &miruro.id)); + assert!(app.views[app.view_index].videos.is_empty()); + app.video_state.save(kind, &youtube).unwrap(); + app.rebuild_collections(); + app.remove_collection(); + assert!(!app.video_state.contains(kind, &youtube.id)); + assert!(app.video_state.contains(kind, &miruro.id)); + } + } + #[test] fn watching_direct_bindings_respect_modals_and_offline_history() { for (label, overrides, binding) in [ @@ -4705,7 +4424,6 @@ mod tests { combined, ViewSource::CategoryGroup(vec!["Alpha".into(), "Beta".into()]) ); - assert_eq!(app.visible_views().count(), 4); rename_press(&mut app, KeyCode::Char('c')); assert_eq!( app.browser @@ -5517,7 +5235,6 @@ mod tests { Some(ViewSource::Category("Zeta".into())) ); assert!(app.config.pinned_categories.is_empty()); - assert_eq!(app.visible_views().count(), 4); assert_eq!( app.visible_views().last().unwrap().1.source, ViewSource::Category("Zeta".into()) @@ -6326,7 +6043,6 @@ mod tests { ViewSource::SavedVideos(None) ); assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - assert_eq!(app.visible_views().count(), 4); assert!(app.job.is_none()); app.views[app.view_index].selection.select(Some(0)); app.preview = Some(PreviewMode::Info); @@ -7709,7 +7425,6 @@ tags.cancel = "z" ViewSource::Search, ViewSource::Feed, ViewSource::Category("@Alpha".into()), - ViewSource::MiruroSearch, ViewSource::Channel("beta".into()) ] ); @@ -7727,7 +7442,6 @@ tags.cancel = "z" app.views[app.view_index].source, ViewSource::Category("Temporary".into()) ); - assert_eq!(app.visible_views().count(), 5); assert!( !app.visible_views() .any(|(_, view)| matches!(view.source, ViewSource::Channel(_))) @@ -7807,7 +7521,7 @@ tags.cancel = "z" assert_eq!(app.views[app.view_index].next_start, 41); app.apply_config(config::Config::default()); assert_eq!(app.view_index, 0); - assert_eq!(app.views.len(), 3); + assert_eq!(app.views.len(), 2); app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); for code in [KeyCode::Down, KeyCode::Enter, KeyCode::Char('p')] { app.key(KeyEvent::new(code, KeyModifiers::NONE)); @@ -8212,8 +7926,7 @@ tags.cancel = "z" .map(|(_, view)| view.name.clone()) .collect::>() }; - assert_eq!(visible(&app), ["Search", "Feed", "Miruro"]); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(visible(&app), ["Search", "Feed"]); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(app.view_index, 0); @@ -8221,31 +7934,29 @@ tags.cancel = "z" app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(app.views[app.view_index].name, "Beta"); - assert_eq!(visible(&app), ["Search", "Feed", "Miruro", "Beta"]); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(visible(&app), ["Search", "Feed", "Miruro", "Gamma"]); + assert_eq!(visible(&app), ["Search", "Feed", "Gamma"]); app.apply_config(config::Config { categories: app.config.categories.clone(), pinned_categories: vec!["Beta".into()], ..config::Config::default() }); - assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Miruro", "Gamma"]); + assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Gamma"]); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(app.view_index, 0); app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); assert_eq!(app.views[app.view_index].name, "Gamma"); app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.views[app.view_index].name, "Miruro"); - app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); assert_eq!(app.views[app.view_index].name, "Beta"); app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Miruro"]); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Miruro"]); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); assert!(app.browser.is_none()); app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))); @@ -9473,7 +9184,7 @@ tags.cancel = "z" #[test] fn empty_feed_is_informative_and_navigation_wraps_without_extraction() { let mut app = app(&["tubular"]); - assert_eq!(app.views.len(), 3); + assert_eq!(app.views.len(), 2); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL)); assert_eq!(app.view_index, 1); assert!(app.views[1].loaded); @@ -9481,11 +9192,9 @@ tags.cancel = "z" assert_eq!(app.views[1].selection.selected(), None); assert!(app.job.is_none()); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].source, ViewSource::MiruroSearch); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(app.view_index, 0); app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.views[app.view_index].source, ViewSource::MiruroSearch); + assert_eq!(app.views[app.view_index].source, ViewSource::Feed); assert!(app.job.is_none()); } diff --git a/src/miruro_browser.rs b/src/miruro_browser.rs index 57d6c68..506383b 100644 --- a/src/miruro_browser.rs +++ b/src/miruro_browser.rs @@ -8,13 +8,56 @@ use std::{ use tubular::miruro::{Client, Detail, Identity, SourceDocument}; use unicode_normalization::UnicodeNormalization; +#[derive(Clone, Debug)] +pub struct Media { + pub id: String, + pub title: String, + // Retain optional/null metadata exactly as returned; it is not a Video. + pub metadata: Value, +} + +#[derive(Clone, Debug)] +pub struct Episode { + pub identity: Identity, + pub title: Option, + pub synopsis: Option, + pub aired_on: Option, + pub duration_seconds: Option, + pub metadata: Value, +} + +impl Episode { + pub fn snapshot(&self, media: &Media) -> Video { + video( + self.identity.key(), + format!( + "{} · Episode {}{}", + media.title, + self.identity.episode, + self.title + .as_ref() + .map(|title| format!(" · {title}")) + .unwrap_or_default() + ), + self.aired_on.clone(), + ) + } +} + +#[derive(Clone, Debug)] +pub struct Series { + pub media: Media, + pub episodes: Vec, +} + pub enum Request { Search(String), Episodes { media_id: String, slug: String }, Source(Video), } pub enum Response { - Rows(Vec<(Video, String)>), + Catalog(Vec), + Series(Series), Source(Video, Box), } @@ -35,12 +78,12 @@ impl Worker { let result = match request { Request::Search(query) => client .search(&query) - .and_then(catalog_rows) - .map(Response::Rows), + .and_then(catalog) + .map(Response::Catalog), Request::Episodes { media_id, slug } => client .detail(&media_id, &slug) - .and_then(|detail| episode_rows(&media_id, detail)) - .map(Response::Rows), + .and_then(|detail| series(&media_id, detail)) + .map(Response::Series), Request::Source(video) => Identity::from_key(&video.id) .and_then(|id| { client.source_document(&id.media_id, id.episode, &video.title) @@ -89,9 +132,6 @@ impl Worker { } } -pub fn is_miruro(video: &Video) -> bool { - video.id.starts_with("miruro:") || video.id.starts_with("miruro-series:") -} fn invalid(message: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } @@ -162,38 +202,48 @@ fn video(id: String, title: String, date: Option) -> Video { publication_date: date, } } -pub fn catalog_rows(items: Vec) -> io::Result> { +fn media(metadata: Value) -> io::Result { + let id = metadata + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| invalid("Miruro catalog ID is missing"))? + .to_owned(); + Identity::new(id.clone(), 1)?; + Ok(Media { + id, + title: title(&metadata)?, + metadata, + }) +} + +pub fn catalog(items: Vec) -> io::Result> { + if items.len() > 8 { + return Err(invalid("Miruro autocomplete exceeded eight results")); + } + let mut seen = std::collections::HashSet::new(); items .into_iter() - .map(|media| { - let id = media - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| invalid("Miruro catalog ID is missing"))?; - Identity::new(id.to_owned(), 1)?; - let title = title(&media)?; - let description = media - .get("description") - .and_then(Value::as_str) - .unwrap_or( - "Catalog title: Enter opens returned episodes; only episodes can be saved.", - ) - .to_owned(); - Ok(( - video(format!("miruro-series:{id}"), title, None), - description, - )) + .map(|item| { + let media = media(item)?; + if !seen.insert(media.id.clone()) { + return Err(invalid("Miruro returned duplicate catalog IDs")); + } + Ok(media) }) .collect() } -pub fn episode_rows(media_id: &str, detail: Detail) -> io::Result> { - let series = title(&detail.media)?; + +pub fn series(media_id: &str, detail: Detail) -> io::Result { + let media = media(detail.media)?; + if media.id != media_id { + return Err(invalid("Miruro detail ID did not match the requested ID")); + } let mut seen = std::collections::HashSet::new(); - detail + let episodes = detail .episodes .into_iter() - .map(|episode| { - let number = episode + .map(|metadata| { + let number = metadata .get("episode_number") .and_then(Value::as_u64) .and_then(|n| u32::try_from(n).ok()) @@ -202,27 +252,23 @@ pub fn episode_rows(media_id: &str, detail: Detail) -> io::Result>>()?; + Ok(Series { media, episodes }) } #[cfg(test)] @@ -230,24 +276,47 @@ mod tests { use super::*; #[test] fn catalog_and_actual_episode_numbers_remain_distinct() { - let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}}); - let rows = catalog_rows(vec![media.clone()]).unwrap(); - assert_eq!(rows[0].0.id, "miruro-series:opaque_ID"); - assert!(Identity::from_key(&rows[0].0.id).is_err()); - let episodes = episode_rows( - "opaque_ID", - Detail { - media, - episodes: vec![ - serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned"}), - ], - }, - ) - .unwrap(); - let identity = Identity::from_key(&episodes[0].0.id).unwrap(); - assert_eq!(identity.episode, 3); - assert_eq!(identity.media_id, "opaque_ID"); - assert!(episodes[0].0.channel_id.is_none()); + let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}, "episode_count":12, "episode_counts":{"sub":null}, "episode_duration_minutes":null}); + let rows = catalog(vec![media.clone()]).unwrap(); + assert_eq!(rows[0].id, "opaque_ID"); + assert!(rows[0].metadata["episode_counts"]["sub"].is_null()); + assert!(catalog(vec![media.clone(), media.clone()]).is_err()); + let episode = serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned","synopsis":"Actual synopsis","aired_on":"2026-10-02","duration_seconds":1500}); + let detail = || Detail { + media: media.clone(), + episodes: vec![episode.clone()], + }; + let data = series("opaque_ID", detail()).unwrap(); + assert_eq!(data.episodes.len(), 1); + assert_eq!(data.episodes[0].identity.episode, 3); + assert_eq!(data.episodes[0].identity.media_id, "opaque_ID"); + assert_eq!(data.episodes[0].duration_seconds, Some(1500)); + assert_eq!( + data.episodes[0].synopsis.as_deref(), + Some("Actual synopsis") + ); + assert!(data.episodes[0].snapshot(&data.media).channel_id.is_none()); + assert!(series("other", detail()).is_err()); + assert!( + series( + "opaque_ID", + Detail { + media: media.clone(), + episodes: vec![episode.clone(), episode] + } + ) + .is_err() + ); + assert!( + series( + "opaque_ID", + Detail { + media, + episodes: vec![serde_json::json!({"absolute_number":49})] + } + ) + .is_err() + ); assert!(slug("薬屋").is_err()); assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title"); assert_eq!(slug(" --Title-- ").unwrap(), "-title-"); diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs new file mode 100644 index 0000000..1c1efaa --- /dev/null +++ b/src/miruro_ui.rs @@ -0,0 +1,1793 @@ +use std::{io, path::PathBuf, time::Duration}; + +use ratatui::{ + DefaultTerminal, Frame, + crossterm::{ + cursor::SetCursorStyle, + event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, + KeyEventKind, + }, + execute, + }, + layout::{Constraint, Layout}, + style::{Color, Style, Stylize}, + text::Line, + widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}, +}; +use serde_json::Value; +use tubular::miruro::Identity; + +use crate::{ + Options, config, + keys::{Action, Context}, + miruro_browser::{self, Episode, Media, Request, Response, Worker}, + video_state::{CollectionKind, State}, + youtube::{PlayerLaunch, Video}, +}; + +#[derive(Clone, Copy, Default)] +enum WatchFilter { + #[default] + All, + Unwatched, + Watched, +} +impl WatchFilter { + fn next(self) -> Self { + match self { + Self::All => Self::Unwatched, + Self::Unwatched => Self::Watched, + Self::Watched => Self::All, + } + } + fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Unwatched => "Unwatched", + Self::Watched => "Watched", + } + } + fn includes(self, watched: bool) -> bool { + match self { + Self::All => true, + Self::Unwatched => !watched, + Self::Watched => watched, + } + } +} + +enum Content { + Catalog { + query: String, + media: Vec, + searched: bool, + }, + Series { + media: Media, + data: Option>, + }, + Collection { + kind: CollectionKind, + filter: WatchFilter, + videos: Vec