diff --git a/README.md b/README.md index 1bd1e60..babe10a 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,15 @@ Tubular requests the Kitty keyboard protocol's escape-code disambiguation for it Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: a 20-second timeout, no redirects, 2 MiB response limit, HTTP/content-type checks and GraphQL-error rejection. Searches return at most eight titles. Nullable artwork, descriptions, scores, planned totals and airing metadata remain unknown rather than being replaced with fabricated values. -**Provider availability is UNVERIFIED.** A finished show's known total supplies catalog episode numbers; a releasing show's `nextAiringEpisode.episode - 1` supplies an airing/catalog count, separate from its planned total. Unreleased shows have no aired numbers. Missing totals/airing information remain unknown, not “zero episodes.” In an unknown-count series, press `i` (configured search/edit), enter a positive canonical episode number, then Enter to select it. Enter again launches that explicit episode. The initial direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons. +**Catalog episode numbers are not source availability.** A finished show's known total supplies catalog episode numbers; a releasing show's `nextAiringEpisode.episode - 1` supplies an airing/catalog count, separate from its planned total. Unreleased shows have no aired numbers. Missing totals/airing information remain unknown, not “zero episodes.” In an unknown-count series, press `i` (configured search/edit), enter a positive canonical episode number, then Enter to select it. Enter again launches that explicit episode. The direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons. -Rows, metadata and selection prompts label availability UNVERIFIED. Terebi resolves the current selection fresh; unavailable episodes, malformed/protected pages and network failures appear as distinct player errors, while launch/catalog errors remain visible in Tubular. A successful handoff does not mark an episode watched. +AniList playable rows in series and offline episode collections have three aligned lines: title, existing metadata/watch markers, and a quiet source-check result. The third line starts as `Source check pending`, becomes `Checking source…`, then shows `ZokoAnime · sub`, `No source found` (only an explicit missing-episode response), or `Source check failed` (network/protection/malformed responses, manifest errors, an older CLI, or launcher failure). A positive check means a fresh source configuration and accessible valid root HLS manifest, **not** a guarantee that every segment or the full episode will play. -AniList episode metadata rows lead with the available series-average runtime (`25 min avg`), format and series year, followed by `sub · UNVERIFIED` and collection/watch markers. Missing or zero average runtimes are omitted. These are explicitly series-level facts, not measured episode durations or individual air dates; legacy Miruro retains its returned episode-specific dates and durations. -Episode metadata aligns with the title text, reserving the same checked/watched marker width on both lines in either anime catalog and episode collections. +Checks run progressively through the configured `--player` executable's headless `--check-source URL` command, one at a time: selected episode first, then only rows actually visible in the current viewport. There is no whole-series scan. Moving the cursor within visible rows lets the current check finish; scrolling it offscreen, navigating away, loading catalog metadata, opening a popup/input/provider chooser, or closing Tubular cancels pending work. Returning to an unobscured episode view resumes checks after its next rendered frame. Each check has a 30-second timeout and a 4 KiB output cap; cancellation kills and reaps the isolated CLI child without a background reader thread. + +Results are session-only by stable episode identity, including failures, so errors do not retry every tick. `r` clears this cache and refreshes the view. Checks do not change saved episodes, watched state, progress, config or stored metadata, and never store resolved URLs. Playback remains available for every status and always resolves the selected episode fresh; a check is not reused as playable media. An older Terebi CLI shows `Source check failed` without disabling playback. A successful handoff does not mark an episode watched. + +The second line retains series-average runtime (`25 min avg`), format and series year followed by collection/watch markers. Missing or zero average runtimes are omitted. These are series-level facts, not measured episode durations or individual air dates; legacy Miruro retains its episode-specific dates/durations and two-line rows. Every line reserves the same checked/watched marker width so its text aligns with the episode title. Identities are `anilist::`, with both numbers bounded by Int32. The stable handoff is `https://zokoanime.video/stream/ani/ID/EPISODE/sub` (`ani`, not the broken `anilist` route). Sources, tokens and subtitle URLs are never stored in collections or playback snapshots. Queues use the same stable URL and per-item private progress files; version-2 reports identify `provider = "anilist"`, decimal-string `media_id`, and numeric `episode`. @@ -104,7 +107,7 @@ cargo test --locked cargo run --locked -- --anilist "Cowboy Bebop" ``` -The probe searches the real catalog, selects exact AniList ID 1, fetches detail, and emits the UNVERIFIED episode-1 identity/stable URL. It does not prove playback. See `docs/anilist-direct-provider-feasibility.md` for historical native source/media evidence and outstanding AniList terms/distribution and provider-availability caveats. +The probe searches the real catalog, selects exact AniList ID 1, fetches detail, and emits the episode-1 identity/stable URL without checking a source. It does not prove playback. For a read-only provider smoke, run `terebi --check-source https://zokoanime.video/stream/ani/1/1/sub`; it prints one version-1 JSON line and does not launch the app. See `docs/anilist-direct-provider-feasibility.md` for historical native source/media evidence and outstanding AniList terms/distribution caveats. ### Miruro legacy browser diff --git a/examples/anilist_probe.rs b/examples/anilist_probe.rs index f98fa2a..ee4c9ea 100644 --- a/examples/anilist_probe.rs +++ b/examples/anilist_probe.rs @@ -26,12 +26,12 @@ fn main() -> io::Result<()> { results.len() ); println!( - "detail: {count} catalog episode numbers; selected episode 1; provider availability UNVERIFIED" + "detail: {count} catalog episode numbers; selected episode 1; source not checked by this probe" ); println!("identity: {}", identity.key()); println!("watch URL: {}", identity.watch_url()?); println!( - "PASS: native AniList search -> exact detail -> UNVERIFIED catalog episode -> stable sub URL (no playback request)" + "PASS: native AniList search -> exact detail -> catalog episode -> stable sub URL (no source check or playback request)" ); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 1db13a2..1d6d043 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ //! //! Startup offers YouTube, AniList and the explicitly separate Miruro legacy catalog. //! `--anilist` opens AniList directly; `--miruro` retains legacy catalog access. -//! AniList episode numbers are catalog metadata, with provider availability UNVERIFIED. +//! AniList episode numbers are catalog metadata; visible episodes receive background source checks. //! Unknown counts allow explicit positive episode selection; Terebi resolves sub on demand. //! Stable provider-scoped identities persist, never resolved sources or subtitle URLs. //! AniList and Miruro show libraries remain separate; existing Miruro IDs are never mapped. @@ -164,7 +164,7 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube or AniList 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.\nAniList browsing uses native GraphQL with no Miruro dependency. Episode numbers have provider availability UNVERIFIED; sub resolves on selection. Unknown-count series accept an explicit positive episode using i. --anilist opens this catalog directly; --miruro is explicitly legacy. Show libraries and episode identities remain provider-isolated. YouTube browsing requires yt-dlp; thumbnails/cover art use curl. Legacy Miruro browsing uses the native catalog client; Terebi must support Miruro episode URLs and version-2 progress. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nMiruro Enter opens catalog episodes, then plays checked episodes in check order, or the hovered episode. One episode uses --new-instance --url URL --progress-file PATH; multiple episodes use --new-instance --queue-file PATH. Terebi resolves each current episode on demand, including sources, subtitles and playback metadata. Episodes use stable catalog/episode identity for collections and progress; channel tags remain YouTube-only.\nFor one YouTube video, Terebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file. Multiple videos use --new-instance --queue-file with an absolute private version-1 JSON manifest containing ordered canonical URLs and independent private progress_file paths. Terebi resolves only the current item on demand; plain Enter opens one independent player without replacing existing playback. Ctrl+Enter (video.enqueue) sends either one video or checked videos using --enqueue --queue-file with the same private manifest format, appending to a running player without interrupting playback, or launching the queue if none is running.\nStartup selects independent YouTube, AniList or Miruro legacy interfaces through a centered provider chooser using configured selector keys; Esc/q quits. --anilist or --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 anime interfaces, input searches the selected catalog (or selects an explicit episode for unknown AniList counts); 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; Space toggles a playback check; Esc discards checks (Ctrl-Space also clears); Enter plays checked videos in check order, or the hovered video when none are checked. These controls also work in collection views and previews. Configure video.select.toggle, video.select.clear and video.enqueue. Checks belong to each view, survive pagination and reordering by video ID, clear on replacement refresh/query and successful launcher spawn, and are pruned when filters or collections hide/remove rows. Failed launches retain checks. Saves, removals and status actions still affect only the hovered video; 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; - 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.\nPlain Enter opens a new Terebi player, leaving existing playback untouched; Ctrl+Enter appends to the running queue instead. 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.\nAniList browsing uses native GraphQL with no Miruro dependency. Episode numbers are catalog metadata; visible episodes are checked separately for ZokoAnime sub sources, and playback resolves fresh. Unknown-count series accept an explicit positive episode using i. --anilist opens this catalog directly; --miruro is explicitly legacy. Show libraries and episode identities remain provider-isolated. YouTube browsing requires yt-dlp; thumbnails/cover art use curl. Legacy Miruro browsing uses the native catalog client; Terebi must support Miruro episode URLs and version-2 progress. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nMiruro Enter opens catalog episodes, then plays checked episodes in check order, or the hovered episode. One episode uses --new-instance --url URL --progress-file PATH; multiple episodes use --new-instance --queue-file PATH. Terebi resolves each current episode on demand, including sources, subtitles and playback metadata. Episodes use stable catalog/episode identity for collections and progress; channel tags remain YouTube-only.\nFor one YouTube video, Terebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file. Multiple videos use --new-instance --queue-file with an absolute private version-1 JSON manifest containing ordered canonical URLs and independent private progress_file paths. Terebi resolves only the current item on demand; plain Enter opens one independent player without replacing existing playback. Ctrl+Enter (video.enqueue) sends either one video or checked videos using --enqueue --queue-file with the same private manifest format, appending to a running player without interrupting playback, or launching the queue if none is running.\nStartup selects independent YouTube, AniList or Miruro legacy interfaces through a centered provider chooser using configured selector keys; Esc/q quits. --anilist or --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 anime interfaces, input searches the selected catalog (or selects an explicit episode for unknown AniList counts); 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; Space toggles a playback check; Esc discards checks (Ctrl-Space also clears); Enter plays checked videos in check order, or the hovered video when none are checked. These controls also work in collection views and previews. Configure video.select.toggle, video.select.clear and video.enqueue. Checks belong to each view, survive pagination and reordering by video ID, clear on replacement refresh/query and successful launcher spawn, and are pruned when filters or collections hide/remove rows. Failed launches retain checks. Saves, removals and status actions still affect only the hovered video; 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; - 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.\nPlain Enter opens a new Terebi player, leaving existing playback untouched; Ctrl+Enter appends to the running queue instead. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" )] struct Options { /// Terebi CLI executable with URL, queue-file and per-item progress support. @@ -179,7 +179,7 @@ struct Options { #[arg(long)] miruro: bool, - /// Open AniList catalog numbers (provider availability UNVERIFIED; sub resolved on play). + /// Open AniList with visible-episode source checks and fresh sub resolution on play. #[arg(long, conflicts_with = "miruro")] anilist: bool, diff --git a/src/miruro_browser.rs b/src/miruro_browser.rs index 9ae8a37..18d9297 100644 --- a/src/miruro_browser.rs +++ b/src/miruro_browser.rs @@ -300,7 +300,7 @@ pub fn anilist_episode(media: &Media, number: u32) -> io::Result { Ok(Episode { identity, title: None, - synopsis: Some("Provider availability UNVERIFIED. Terebi resolves this sub episode on selection; catalog numbering does not guarantee a stream.".into()), + synopsis: Some("Catalog numbering does not guarantee a stream. Visible episodes are checked separately for ZokoAnime sub sources; playback always resolves fresh.".into()), aired_on: None, duration_seconds: None, metadata: Value::Null, diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs index 7d1d951..dacce15 100644 --- a/src/miruro_ui.rs +++ b/src/miruro_ui.rs @@ -33,6 +33,9 @@ use crate::{ youtube::{PlayerLaunch, Video}, }; +#[path = "source_check.rs"] +mod source_check; + #[derive(Clone, Copy, Default)] enum WatchFilter { #[default] @@ -245,7 +248,7 @@ impl Screen { self.overview = media_details(media); if let Some(data) = data { if self.provider == Provider::AniList { - self.overview.push_str("\n\nProvider availability UNVERIFIED. Sub only; resolved on selection, not a stream inventory."); + self.overview.push_str("\n\nCatalog numbers are not a stream inventory. Visible episodes are checked for a fresh ZokoAnime sub source; playback resolves again."); if media .metadata .get("catalog_count") @@ -299,7 +302,6 @@ impl Screen { { details.push(format!("{year} series")); } - details.push("sub · UNVERIFIED".into()); } let details = details.join(" · "); let mut metadata = title.clone(); @@ -349,7 +351,7 @@ impl Screen { self.rows = videos.iter().map(|video| { let identity = Identity::from_key(&video.id).expect("provider-scoped collection"); let title = safe(&video.title); - let metadata = format!("{title}\n\nEpisode {} · {} ID {}\n\nStored offline episode metadata. Provider availability UNVERIFIED. Enter sends the stable episode URL to Terebi for fresh resolution.", identity.episode, self.provider.name(), identity.media_id); + let metadata = format!("{title}\n\nEpisode {} · {} ID {}\n\nStored offline episode metadata. Catalog numbers do not establish source availability. Enter sends the stable episode URL to Terebi for fresh resolution.", identity.episode, self.provider.name(), identity.media_id); DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata } }).collect(); } @@ -408,6 +410,8 @@ pub(crate) struct Browser { pending: Option, intent: Option, player: Option, + source_checks: source_check::Checks, + source_visible: Vec, } enum Intent { Catalog, @@ -514,6 +518,8 @@ impl Browser { pending: None, intent: None, player: None, + source_checks: source_check::Checks::default(), + source_visible: Vec::new(), }; browser.screens[0].provider = provider; browser.screens[0].rebuild_display(); @@ -529,7 +535,53 @@ impl Browser { fn screen_mut(&mut self) -> &mut Screen { &mut self.screens[self.index] } + fn sources_exposed(&self) -> bool { + self.provider == Provider::AniList + && self.screen().playable() + && self.intent.is_none() + && self.pending.is_none() + && self.input.is_none() + && self.preview.is_none() + && self.help.is_none() + && self.removal.is_none() + && self.modal.is_none() + } + fn update_visible_sources(&mut self, height: u16) { + if !self.sources_exposed() { + self.source_visible.clear(); + return; + } + // ListState's offset is updated by rendering, not by cursor movement. + // Only complete three-line rows fit; a spare terminal line is not another episode. + let screen = &self.screens[self.index]; + let keys = screen + .rows + .iter() + .skip(screen.selection.offset()) + .take(usize::from(height) / 3) + .filter_map(|row| row.key.as_ref()) + .filter(|key| key.starts_with("anilist:")); + if self.source_visible.iter().eq(keys.clone()) { + return; + } + self.source_visible.clear(); + self.source_visible.extend(keys.cloned()); + } + fn update_source_checks(&mut self, now: Instant) { + if !self.sources_exposed() { + self.source_visible.clear(); + } + self.source_checks.update( + self.generation, + &self.source_visible, + self.screens[self.index].selected_key(), + &self.options.player, + now, + ); + } fn invalidate(&mut self) { + self.source_checks.cancel(); + self.source_visible.clear(); self.generation = self.generation.wrapping_add(1); self.pending = None; self.intent = None; @@ -581,7 +633,7 @@ impl Browser { self.input = Some(String::new()); self.episode_input = true; self.status = - "Enter an explicit episode number; availability UNVERIFIED (sub only).".into(); + "Enter an explicit episode number; source checked separately (sub only).".into(); return; } if !matches!(self.screen().content, Content::Catalog { .. }) { @@ -622,7 +674,7 @@ impl Browser { match result { Ok(()) => { self.screen_mut().error = None; - self.status = "Explicit episode selected; provider availability UNVERIFIED. Enter resolves sub on demand.".into(); + self.status = "Explicit episode selected; source checked separately. Enter resolves sub fresh.".into(); } Err(error) => self.fail(error), } @@ -1151,6 +1203,7 @@ impl Browser { } fn refresh(&mut self) { self.invalidate(); + self.source_checks.clear(); self.screen_mut().checked.clear(); match config::load(&self.config_path) { Ok(config) => { @@ -1447,7 +1500,7 @@ impl Browser { ( Action::InputSubmit, if self.episode_input { - "Select explicit episode (UNVERIFIED)" + "Select explicit episode (sub)" } else { "Search catalog" }, @@ -1815,7 +1868,7 @@ impl Browser { } else { " · " }; - ListItem::new(vec![ + let mut lines = vec![ Line::from(vec![ ratatui::text::Span::raw(if self.screen().playable() { if row @@ -1846,13 +1899,29 @@ impl Browser { ratatui::text::Span::raw(markers), ]) .dim(), - ]) + ]; + if self.provider == Provider::AniList && self.screen().playable() { + let label = row + .key + .as_deref() + .map(|key| self.source_checks.label(key)) + .unwrap_or("Source check pending"); + lines.push( + Line::from(vec![ + ratatui::text::Span::raw(" "), + ratatui::text::Span::raw(label), + ]) + .dim(), + ); + } + ListItem::new(lines) }) .collect() } fn draw(&mut self, frame: &mut Frame, elapsed: Duration) { let area = frame.area(); if area.width < 24 || area.height < 10 { + self.source_visible.clear(); frame.render_widget( Paragraph::new(format!( "{}\nTerminal too small; enlarge to browse.\n{} exits", @@ -1889,7 +1958,7 @@ impl Browser { frame, query_area, if self.episode_input { - " Explicit episode · UNVERIFIED · sub " + " Explicit episode · sub " } else if self.provider == Provider::AniList { " tubular / AniList search " } else { @@ -1908,7 +1977,9 @@ impl Browser { "", self.intent.is_some().then(|| ui::spinner(elapsed)), )); + let viewport_height = block.inner(content).height; if self.screen().rows.is_empty() { + self.source_visible.clear(); let empty = if let Some(error) = &self.screen().error { format!( "{error}\n\n{} refreshes; {} searches the catalog.", @@ -1919,9 +1990,9 @@ impl Browser { format!("Loading {} metadata…", self.provider.name()) } else { match &self.screen().content { - Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result for episode metadata; AniList provider availability is UNVERIFIED.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)), + Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result for catalog episodes; visible AniList episodes are checked separately for sources.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)), Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(), - Content::Series { media, .. } if self.provider == Provider::AniList && media.metadata.get("catalog_count").is_none_or(Value::is_null) => format!("Catalog/airing episode count unknown, not zero. {} selects an explicit positive episode number. Provider availability UNVERIFIED; sub resolved on selection.", self.config.keys.label(Action::SearchEdit)), + Content::Series { media, .. } if self.provider == Provider::AniList && media.metadata.get("catalog_count").is_none_or(Value::is_null) => format!("Catalog/airing episode count unknown, not zero. {} selects an explicit positive episode number. Source availability is checked separately; sub resolves fresh on play.", self.config.keys.label(Action::SearchEdit)), Content::Series { .. } if self.provider == Provider::AniList => "No catalog/airing episode numbers known yet. Planned totals do not establish aired episodes or streams.".into(), Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(), Content::Collection { .. } => format!("No {} episodes in this collection/filter. Other catalogs and YouTube are separate.", self.provider.name()), @@ -1943,6 +2014,7 @@ impl Browser { &mut selection, ); self.screen_mut().selection = selection; + self.update_visible_sources(viewport_height); } frame.render_widget( Paragraph::new(self.status.as_str()) @@ -2054,7 +2126,7 @@ impl Browser { } else if let Some(scroll) = self.help { let mut lines = vec![ Line::from(format!( - "{} catalog → series → episodes · availability UNVERIFIED", + "{} catalog → series → episodes · sources checked separately", self.provider.name() )) .bold(), @@ -2276,7 +2348,7 @@ fn media_summary(media: &Media) -> String { { parts.push("total unknown".into()); } - parts.push("provider availability UNVERIFIED · sub".into()); + parts.push("catalog numbers · sub sources checked separately".into()); } for (field, label) in [ ("raw", "raw listed"), @@ -2351,6 +2423,15 @@ impl Browser { pub(crate) fn run( &mut self, terminal: &mut DefaultTerminal, + ) -> io::Result> { + let result = self.run_inner(terminal); + self.source_checks.cancel(); + self.source_visible.clear(); + result + } + fn run_inner( + &mut self, + terminal: &mut DefaultTerminal, ) -> io::Result> { self.state.select_catalog(self.provider); struct RestoreTerminalInput; @@ -2374,12 +2455,15 @@ impl Browser { loop { self.update(); terminal.draw(|frame| self.draw(frame, started.elapsed()))?; + self.update_source_checks(Instant::now()); if event::poll(Duration::from_millis(100))? { match event::read()? { Event::Key(key) if key.kind != KeyEventKind::Release => { if self.config.keys.resolve(self.context(), key) == Some(Action::ProviderChoose) { + self.source_checks.cancel(); + self.source_visible.clear(); return crate::choose_provider( terminal, &self.config.keys, diff --git a/src/source_check.rs b/src/source_check.rs new file mode 100644 index 0000000..243e6e7 --- /dev/null +++ b/src/source_check.rs @@ -0,0 +1,375 @@ +//! Session-only availability checks. Never retain resolved media or provider diagnostics. +use std::{ + collections::BTreeMap, + ffi::OsStr, + io::{self, Read}, + process::{Child, ChildStdout, Stdio}, + time::{Duration, Instant}, +}; + +use serde::Deserialize; +use tubular::anime::{Identity, Provider}; + +const TIMEOUT: Duration = Duration::from_secs(30); +const OUTPUT_LIMIT: usize = 4096; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(super) enum Status { + Available, + Unavailable, + Failed, +} +impl Status { + pub(super) fn label(self) -> &'static str { + match self { + Self::Available => "ZokoAnime · sub", + Self::Unavailable => "No source found", + Self::Failed => "Source check failed", + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Reply { + version: u32, + provider: String, + track: String, + status: Status, +} +fn decode(bytes: &[u8], success: bool) -> Status { + if !success || bytes.len() > OUTPUT_LIMIT { + return Status::Failed; + } + let Ok(text) = std::str::from_utf8(bytes) else { + return Status::Failed; + }; + let text = text.strip_suffix('\n').unwrap_or(text); + if text.contains(['\n', '\r']) { + return Status::Failed; + } + match serde_json::from_str::(text) { + Ok(reply) + if reply.version == 1 && reply.provider == "zokoanime" && reply.track == "sub" => + { + reply.status + } + _ => Status::Failed, + } +} + +struct Job { + child: Child, + stdout: ChildStdout, + bytes: Vec, + deadline: Instant, + eof: bool, +} +impl Job { + fn start(player: &OsStr, key: &str, now: Instant) -> io::Result { + let identity = Identity::from_key(key)?; + if identity.provider != Provider::AniList.key() { + return Err(io::Error::other("Unsupported source check")); + } + let mut command = crate::youtube::isolated_command(player); + command.arg("--check-source").arg(identity.watch_url()?); + Self::spawn(&mut command, now) + } + fn spawn(command: &mut std::process::Command, now: Instant) -> io::Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let stdout = child.stdout.take().expect("piped stdout"); + let mut job = Self { + child, + stdout, + bytes: Vec::new(), + deadline: now + TIMEOUT, + eof: false, + }; + job.nonblocking()?; + Ok(job) + } + #[cfg(unix)] + fn nonblocking(&mut self) -> io::Result<()> { + use std::os::fd::AsRawFd; + let fd = self.stdout.as_raw_fd(); + // SAFETY: stdout owns this live descriptor; preserve the existing flags. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + #[cfg(not(unix))] + fn nonblocking(&mut self) -> io::Result<()> { + Err(io::Error::other( + "Source checks require nonblocking process pipes", + )) + } + fn poll(&mut self, now: Instant) -> Option { + if now >= self.deadline { + return Some(Status::Failed); + } + // Each poll consumes at most the protocol limit plus one byte, even from a noisy CLI. + let mut buffer = [0; OUTPUT_LIMIT + 1]; + while !self.eof { + let remaining = OUTPUT_LIMIT + 1 - self.bytes.len(); + match self.stdout.read(&mut buffer[..remaining]) { + Ok(0) => self.eof = true, + Ok(count) => { + self.bytes.extend_from_slice(&buffer[..count]); + if self.bytes.len() > OUTPUT_LIMIT { + return Some(Status::Failed); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) if error.kind() == io::ErrorKind::Interrupted => break, + Err(_) => return Some(Status::Failed), + } + } + match self.child.try_wait() { + Ok(Some(status)) if self.eof => Some(decode(&self.bytes, status.success())), + Ok(_) => None, + Err(_) => Some(Status::Failed), + } + } +} +impl Drop for Job { + fn drop(&mut self) { + // The isolated group may still have descendants holding stdout after the leader exits. + #[cfg(unix)] + unsafe { + libc::kill(-(self.child.id() as i32), libc::SIGKILL); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +struct Active { + generation: u64, + key: String, + job: Job, +} +#[derive(Default)] +pub(super) struct Checks { + cache: BTreeMap, + active: Option, +} +impl Checks { + pub(super) fn cancel(&mut self) { + self.active = None; + } + pub(super) fn clear(&mut self) { + self.cancel(); + self.cache.clear(); + } + pub(super) fn status(&self, key: &str) -> Option { + self.cache.get(key).copied() + } + pub(super) fn label(&self, key: &str) -> &'static str { + if let Some(status) = self.status(key) { + status.label() + } else if self.active.as_ref().is_some_and(|active| active.key == key) { + "Checking source…" + } else { + "Source check pending" + } + } + fn next<'a>(&self, visible: &'a [String], selected: Option<&'a str>) -> Option<&'a str> { + selected + .filter(|key| { + visible.iter().any(|item| item.as_str() == *key) && !self.cache.contains_key(*key) + }) + .or_else(|| { + visible + .iter() + .find(|key| !self.cache.contains_key(*key)) + .map(String::as_str) + }) + } + fn finish( + &mut self, + generation: u64, + result_generation: u64, + key: String, + status: Status, + visible: &[String], + ) { + if generation == result_generation && visible.contains(&key) { + self.cache.insert(key, status); + } + } + pub(super) fn update( + &mut self, + generation: u64, + visible: &[String], + selected: Option<&str>, + player: &OsStr, + now: Instant, + ) { + if self + .active + .as_ref() + .is_some_and(|active| active.generation != generation || !visible.contains(&active.key)) + { + self.cancel(); + } + // Cursor moves do not starve a check that is still visible. New selection gets the next turn. + if let Some(status) = self.active.as_mut().and_then(|active| active.job.poll(now)) { + let active = self.active.take().expect("completed check"); + self.finish(generation, active.generation, active.key, status, visible); + } + if self.active.is_none() + && let Some(key) = self.next(visible, selected) + { + let key = key.to_owned(); + match Job::start(player, &key, now) { + Ok(job) => { + self.active = Some(Active { + generation, + key, + job, + }) + } + Err(_) => { + self.cache.insert(key, Status::Failed); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn clock() -> Instant { + // All transitions derive from this fixed injected instant; no sleeps or wall-clock assertions. + *std::sync::LazyLock::force(&CLOCK) + } + static CLOCK: std::sync::LazyLock = std::sync::LazyLock::new(Instant::now); + #[test] + fn protocol_distinguishes_missing_from_failure_and_rejects_other_output() { + for (status, expected) in [ + ("available", Status::Available), + ("unavailable", Status::Unavailable), + ("failed", Status::Failed), + ] { + let text = format!( + r#"{{"version":1,"provider":"zokoanime","track":"sub","status":"{status}"}}"# + ); + assert_eq!(decode(text.as_bytes(), true), expected); + assert_eq!(decode(text.as_bytes(), false), Status::Failed); + } + for text in [ + "", + "old CLI help", + r#"{"version":2,"provider":"zokoanime","track":"sub","status":"available"}"#, + r#"{"version":1,"provider":"other","track":"sub","status":"available"}"#, + r#"{"version":1,"provider":"zokoanime","track":"dub","status":"available"}"#, + ] { + assert_eq!(decode(text.as_bytes(), true), Status::Failed); + } + assert_eq!(decode(&vec![b' '; OUTPUT_LIMIT + 1], true), Status::Failed); + assert_eq!(decode(b"{}\n{}", true), Status::Failed); + } + #[test] + fn selected_then_visible_and_no_retry_until_refresh() { + let mut checks = Checks::default(); + let visible = vec!["anilist:1:3".into(), "anilist:1:4".into()]; + assert_eq!( + checks.next(&visible, Some("anilist:1:4")), + Some("anilist:1:4") + ); + assert_eq!( + checks.next(&visible, Some("anilist:1:99")), + Some("anilist:1:3") + ); + for key in &visible { + checks.finish(1, 1, key.clone(), Status::Failed, &visible); + } + assert_eq!(checks.next(&visible, Some("anilist:1:4")), None); + checks.clear(); + assert_eq!( + checks.next(&visible, Some("anilist:1:4")), + Some("anilist:1:4") + ); + } + #[test] + fn late_generation_or_offscreen_results_do_not_enter_cache() { + let mut checks = Checks::default(); + let visible: Vec = vec!["anilist:1:3".into()]; + checks.finish(2, 1, visible[0].clone(), Status::Available, &visible); + checks.finish(2, 2, "anilist:1:2".into(), Status::Available, &visible); + assert!(checks.cache.is_empty()); + checks.finish(2, 2, visible[0].clone(), Status::Unavailable, &visible); + assert_eq!(checks.cache.get(&visible[0]), Some(&Status::Unavailable)); + } + #[cfg(unix)] + fn waiting_job() -> Job { + let mut command = crate::youtube::isolated_command(OsStr::new("/bin/sh")); + command.args(["-c", "kill -STOP $$"]); + Job::spawn(&mut command, clock()).unwrap() + } + #[cfg(unix)] + #[test] + fn injected_timeout_and_drop_reap_the_child() { + let mut job = waiting_job(); + let pid = job.child.id(); + assert_eq!(job.poll(clock() + TIMEOUT), Some(Status::Failed)); + drop(job); + let mut status = 0; + assert_eq!( + unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }, + -1 + ); + assert_eq!( + io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); + } + #[cfg(unix)] + #[test] + fn cancel_offscreen_or_generation_but_not_visible_deselection() { + let visible: Vec = vec!["anilist:1:3".into(), "anilist:1:4".into()]; + let mut checks = Checks { + active: Some(Active { + generation: 7, + key: visible[0].clone(), + job: waiting_job(), + }), + ..Checks::default() + }; + checks.update( + 7, + &visible, + Some(&visible[1]), + OsStr::new("/missing-player"), + clock(), + ); + assert_eq!(checks.active.as_ref().unwrap().key, visible[0]); + checks.update(7, &[], None, OsStr::new("/missing-player"), clock()); + assert!(checks.active.is_none()); + assert!(checks.cache.is_empty()); + checks.active = Some(Active { + generation: 7, + key: visible[0].clone(), + job: waiting_job(), + }); + checks.update(8, &[], None, OsStr::new("/missing-player"), clock()); + assert!(checks.active.is_none()); + } + #[cfg(unix)] + #[test] + fn old_cli_exit_without_protocol_is_failed_without_blocking_playback_contract() { + let mut job = Job::start(OsStr::new("/usr/bin/true"), "anilist:1:1", clock()).unwrap(); + job.child.wait().unwrap(); + assert_eq!(job.poll(clock()), Some(Status::Failed)); + assert!(Job::start(OsStr::new("/usr/bin/true"), "miruro:1:1", clock()).is_err()); + assert!(Job::start(OsStr::new("/missing-player"), "anilist:1:1", clock()).is_err()); + } +} diff --git a/src/youtube.rs b/src/youtube.rs index 73b921c..e66767c 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -812,7 +812,7 @@ fn read_thumbnail(pipe: impl Read) -> io::Result> { Ok(bytes) } -fn isolated_command(program: &OsStr) -> Command { +pub(crate) fn isolated_command(program: &OsStr) -> Command { let mut command = Command::new(program); #[cfg(unix)] { diff --git a/tests/unit/miruro_ui.rs b/tests/unit/miruro_ui.rs index 0a744fb..d2c2572 100644 --- a/tests/unit/miruro_ui.rs +++ b/tests/unit/miruro_ui.rs @@ -824,3 +824,204 @@ fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collection assert!(matches!(browser.screen().content, Content::Catalog { .. })); assert!(!browser.episode_input); } + +fn anilist_browser(directory: &Directory, count: u32) -> Browser { + let mut browser = directory.browser(None); + browser.provider = Provider::AniList; + browser.options.anilist = true; + browser.options.miruro = false; + browser.state.select_catalog(Provider::AniList); + let series = miruro_browser::anilist_series(serde_json::json!({ + "id":"1", "catalog_provider":"anilist", "title":{"romaji":"Dragon Ball"}, + "status":"FINISHED", "episode_count":count, "catalog_count":count, + "episode_duration_minutes":25, "format":"TV", "season_year":1986 + })) + .unwrap(); + browser.push(Screen::new(Content::Series { + media: series.media, + data: Some(series.episodes), + })); + browser +} + +#[test] +fn source_candidates_use_rendered_offset_and_three_line_viewport_not_series_size() { + use ratatui::{Terminal, backend::TestBackend}; + let directory = Directory::new(); + let mut browser = anilist_browser(&directory, 153); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| browser.draw(frame, Duration::ZERO)) + .unwrap(); + let initial = browser.source_visible.clone(); + assert!(initial.len() < 153); + assert_eq!(initial.first().unwrap(), "anilist:1:1"); + let [_, _, content, _, _] = ui::browser_layout(terminal.backend().buffer().area, 1); + assert_eq!( + initial.len(), + usize::from(content.height.saturating_sub(2)) / 3 + ); + browser.select(Action::VideoLast); + terminal + .draw(|frame| browser.draw(frame, Duration::ZERO)) + .unwrap(); + let offset = browser.screen().selection.offset(); + assert!(offset > 0); + assert_eq!( + browser.source_visible.first().unwrap(), + &format!("anilist:1:{}", offset + 1) + ); + assert!(browser.source_visible.contains(&"anilist:1:153".into())); + assert!( + !browser + .source_visible + .iter() + .any(|key| initial.contains(key)) + ); + let selection = browser.screen().selected_key().unwrap().to_owned(); + let now = Instant::now(); // Inject one fixed clock for every scheduler transition in this test. + browser.update_source_checks(now); + assert_eq!( + browser.source_checks.status(&selection), + Some(source_check::Status::Failed) + ); + assert_eq!(browser.screen().selected_key(), Some(selection.as_str())); + browser.update_source_checks(now); + assert_eq!(browser.screen().selected_key(), Some(selection.as_str())); + assert_eq!(browser.screen().len(), 153); +} + +#[test] +fn source_eligibility_pauses_under_popups_loading_and_catalogs_then_resumes_from_frame() { + let directory = Directory::new(); + let mut browser = anilist_browser(&directory, 10); + let candidates = |browser: &mut Browser| { + browser.update_visible_sources(8); + browser.source_visible.clone() + }; + assert_eq!(candidates(&mut browser), ["anilist:1:1", "anilist:1:2"]); + browser.help = Some(0); + assert!(candidates(&mut browser).is_empty()); + browser.help = None; + browser.preview = Some(PreviewMode::Info); + assert!(candidates(&mut browser).is_empty()); + browser.preview = None; + browser.input = Some(String::new()); + assert!(candidates(&mut browser).is_empty()); + browser.input = None; + browser.removal = Some((CollectionKind::Saved, vec![])); + assert!(candidates(&mut browser).is_empty()); + browser.removal = None; + browser.modal = Some(LibraryModal::Categories { + names: vec![], + checked: BTreeSet::new(), + picker: Picker::default(), + }); + assert!(candidates(&mut browser).is_empty()); + browser.modal = None; + browser.request( + Request::Episodes { + media_id: "1".into(), + slug: String::new(), + }, + Intent::Series("1".into()), + ); + assert!(candidates(&mut browser).is_empty()); + browser.invalidate(); + assert!(browser.source_visible.is_empty()); + assert_eq!(candidates(&mut browser).len(), 2); + browser.history(false); + assert!(candidates(&mut browser).is_empty()); + browser.history(true); + assert_eq!(candidates(&mut browser).len(), 2); + browser.provider = Provider::Miruro; + assert!(candidates(&mut browser).is_empty()); +} + +#[test] +fn source_checks_are_session_only_and_collection_rows_keep_selection_checks_and_watch_state() { + use ratatui::{Terminal, backend::TestBackend}; + let directory = Directory::new(); + let mut browser = anilist_browser(&directory, 10); + let video = browser.screen().selected_video().unwrap(); + browser.save(CollectionKind::Saved); + browser.set_watch_status(WatchStatus::Watched); + browser.collection(CollectionKind::Saved); + browser.screen_mut().toggle_checked(); + let checks = browser.screen().checked.clone(); + let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap(); + let watch = fs::read(directory.0.join("watch-state.toml")).unwrap(); + let config = fs::read(&browser.config_path).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| browser.draw(frame, Duration::ZERO)) + .unwrap(); + assert_eq!( + browser.source_visible.as_slice(), + std::slice::from_ref(&video.id) + ); + assert_eq!(browser.rows()[0].height(), 3); + let now = Instant::now(); // Fixed injected clock; no elapsed-time dependency. + browser.update_source_checks(now); + assert_eq!( + browser.source_checks.status(&video.id), + Some(source_check::Status::Failed) + ); + assert_eq!(browser.screen().selected_key(), Some(video.id.as_str())); + assert_eq!(browser.screen().checked, checks); + assert!(browser.state.is_watched(&video.id)); + assert_eq!( + fs::read(directory.0.join("saved-videos.toml")).unwrap(), + saved + ); + assert_eq!( + fs::read(directory.0.join("watch-state.toml")).unwrap(), + watch + ); + assert_eq!(fs::read(&browser.config_path).unwrap(), config); + browser.refresh(); + assert_eq!(browser.source_checks.status(&video.id), None); + assert_eq!(browser.screen().selected_key(), Some(video.id.as_str())); + browser.provider = Provider::Miruro; + assert_eq!(browser.rows()[0].height(), 2); +} + +#[test] +fn episode_three_lines_align_after_shared_marker_and_leave_metadata_second() { + use ratatui::{Terminal, backend::TestBackend}; + let directory = Directory::new(); + let mut browser = anilist_browser(&directory, 2); + let video = browser.screen().selected_video().unwrap(); + browser.set_watch_status(WatchStatus::Watched); + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + terminal + .draw(|frame| browser.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let find = |needle: &str| { + (0..buffer.area.height) + .find_map(|y| { + let line = (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::(); + line.find(needle) + .map(|byte| (line[..byte].chars().count(), y)) + }) + .unwrap() + }; + let title = find("Episode 1"); + let metadata = find("25 min avg"); + let source = find(browser.source_checks.label(&video.id)); + assert_eq!(metadata, (title.0, title.1 + 1)); + assert_eq!(source, (title.0, title.1 + 2)); + assert_eq!(browser.screen().selected_key(), Some(video.id.as_str())); + for metadata in ["25 min avg", "TV", "1986 series"] { + assert!(browser.screen().rows[0].details.contains(metadata)); + } + browser.screen_mut().toggle_checked(); + terminal + .draw(|frame| browser.draw(frame, Duration::ZERO)) + .unwrap(); + assert!(browser.state.is_watched(&video.id)); + assert_eq!(browser.screen().checked, [video.id]); +}