From 0e909001d0f41809b3aafa45f082ae2b67936544 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 22:23:15 +0200 Subject: [PATCH] feat: integrate Miruro playback and episode progress --- Cargo.lock | 16 ++ Cargo.toml | 3 +- README.md | 34 ++- docs/miruro-api.md | 75 +++++- src/main.rs | 419 ++++++++++++++++++++++++++++++--- src/miruro.rs | 353 +++++++++++++++++++++++++++- src/miruro_browser.rs | 276 ++++++++++++++++++++++ src/video_state.rs | 535 +++++++++++++++++++++++++++++++++++++++--- src/youtube.rs | 57 ++++- 9 files changed, 1676 insertions(+), 92 deletions(-) create mode 100644 src/miruro_browser.rs diff --git a/Cargo.lock b/Cargo.lock index 7aec098..3687cc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1380,6 +1380,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "toml_datetime" version = "1.1.2+spec-1.1.0" @@ -1430,6 +1436,7 @@ dependencies = [ "serde_json", "time", "toml_edit", + "unicode-normalization", "ureq", ] @@ -1439,6 +1446,15 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/Cargo.toml b/Cargo.toml index e829de6..8bcba8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "tubular" version = "0.1.0" edition = "2024" -description = "A terminal YouTube browser that opens selected videos in Terebi" +description = "A terminal YouTube and Miruro browser that opens selected videos in Terebi" [dependencies] clap = { version = "4.6.7", features = ["derive"] } @@ -13,6 +13,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.151", features = ["raw_value"] } toml_edit = "0.25.17" time = "0.3.55" +unicode-normalization = "0.1.25" ureq = { version = "3.4.2", default-features = false, features = ["rustls"] } [target."cfg(unix)".dependencies] diff --git a/README.md b/README.md index 00f2279..9490d28 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # tubular -A modal terminal YouTube browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos, organize subscribed channels with overlapping tags, browse their feeds, and open a selected video in Terebi. +A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos or anime episodes, organize subscribed YouTube channels with overlapping tags, and open a selection in Terebi. ## Requirements - A recent stable Rust toolchain, including Cargo, to build and install. -- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for searches, feeds, and full descriptions. +- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; Miruro uses the native API client. - `curl` on `PATH` for thumbnails. -- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. It must support `--new-instance --url … --progress-file PATH`. +- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. YouTube requires `--new-instance --url … --progress-file PATH`; Miruro additionally requires `--source-file PATH` and version-2 episode progress support. The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player. @@ -34,6 +34,7 @@ cargo run --locked -- "rust programming" ```sh tubular # Start without a query tubular "rust programming" # Search immediately +tubular --miruro "apothecary" # Search Miruro instead tubular --player /path/to/terebi "japanese podcast" tubular --help ``` @@ -49,6 +50,15 @@ 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 + +Use `--miruro` at startup, 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. + +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. + +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 These are the default bindings. Overrides in `[keys]` replace an action's bindings; the live hints and `?` reference show the configured keys. @@ -58,9 +68,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 the selected video in Terebi | +| Enter | Open a Miruro catalog title's episodes, or launch the selected video/episode in Terebi | | `g` / Home, `G` / End | Select the first / last loaded video | -| `i` | Edit the current category/combined feed's search, or general Search from other views | +| `i` | Edit the current category/combined feed or Miruro 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 | @@ -205,7 +215,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** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates. +- **Search**, **Feed**, and **Miruro** are permanent 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. @@ -214,7 +224,7 @@ 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. Category lists are derived from the saved videos' channel identities and the **current** subscription categories: adding or removing a channel from a category changes its watch-later list after saving tags or reloading config with `r`. No category memberships are stored on individual videos. +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. `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. @@ -234,15 +244,15 @@ Files are siblings of `config.toml`: - `saved-videos.toml`: independent versioned saved-video metadata. - `currently-watching.toml`: versioned metadata for started, unfinished videos. - `watch-state.toml`: versioned global watched video IDs. -- `playback-sessions/`: private per-launch progress streams, expected video identities, video snapshots, and durable admission markers. +- `playback-sessions/`: private per-launch progress streams, expected identities, metadata snapshots, durable admission markers, and Miruro source documents. State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB. -Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. They are retained because the GUI may still be writing; up to 4,096 sessions are monitored. If that limit is reached, close the corresponding players, remove their inactive session directories, and restart Tubular. Never remove an active player's reporting directory. +Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. Miruro source documents are `0600` files in `0700` session directories and remain unchanged, even after launcher exit or reported end/close; they contain private, expiring resource URLs and headers. Sessions are retained because CLI exit does not prove the independent GUI has finished; up to 4,096 are monitored. Close the corresponding players before removing their inactive session directories, then restart Tubular. Never remove an active player's directory. ## Saved videos -Saved videos is a separate collection from watch later, deduplicated by YouTube video ID. It uses the same offline title, creator, and publication metadata and follows **current channel category memberships**, not categories stored on individual videos. +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). `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. @@ -264,7 +274,7 @@ Use `x` for individual removal or `X` for bulk confirmation. Removal neither mar ## Collection removal -Removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. Search, Feed, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls. +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. @@ -322,7 +332,7 @@ Video info shows a thumbnail, title, author/publication details, and a clipped d ## Development -[Miruro API notes](docs/miruro-api.md) document the native `tubular::miruro` wrapper and verified transport behavior. Miruro is not yet integrated into the TUI or Terebi playback. +[Miruro API notes](docs/miruro-api.md) document the native `tubular::miruro` wrapper, source-document projection, stable episode/progress contracts, and verification limits. ```sh cargo fmt --check diff --git a/docs/miruro-api.md b/docs/miruro-api.md index 5a7fe56..f44650f 100644 --- a/docs/miruro-api.md +++ b/docs/miruro-api.md @@ -1,6 +1,6 @@ # Miruro API research -Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**. These are reverse-engineering notes, not an official API specification. Tubular now has a native API module and runnable smoke example, but no Miruro TUI or Terebi integration. +Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**. These are reverse-engineering notes, not an official API specification. Tubular includes a native API module, Miruro catalog/episode browsing, and direct Sun playback through Terebi's implemented source/progress contracts. Evidence labels: @@ -263,7 +263,7 @@ Observed `providerOrder`: `sun, bee, pewe, hop, ally, kiwi, moo, bonk, knob, nun - The browser loads [strm.cx/client.js][strm-client], renders ``, and calls `setSession` with local player state/source/subtitle/skip data over `strmcx/v1` messaging. This was **not required for the tested source or direct-resource GETs**. It is not evidence of an authenticated API session prerequisite. - strm client contains a best-effort `POST /v1/embeds`; it was source-inspected only, not invoked. Iframe providers and unrelated comment/telemetry endpoints were not exercised. -For a future native player: preserve fresh query tokens, per-server headers and relevant provider config; correctly resolve nested/relative HLS URLs. Do not apply one provider's headers indiscriminately to another provider's subtitles/thumbnails. Terebi currently receives only `--new-instance --url … --progress-file PATH` in Tubular ([existing usage](../README.md#usage)); custom headers, external subtitles, HLS overrides, native seeking and full Miruro playback remain unverified. +Tubular's direct Sun integration preserves fresh URLs and explicitly supplies the selected server's headers to both its stream and verified VTT subtitle resources. Terebi owns native HLS resolution and subtitle loading; other providers' proxies, headers and playlist transforms remain unverified and unsupported. See [the adopted integration contract](#tubular--terebi-integration). ## SvelteKit route data @@ -399,8 +399,10 @@ Library interface, exposed as `tubular::miruro`: | `client.search(query)` | `io::Result>`; trimmed, nonempty autocomplete query; no cursor pagination | | `client.detail(id, slug)` | `io::Result`; decoded `media` JSON and `episodes: Vec`; validates returned media ID | | `client.sources(id, episode)` | `io::Result`; fresh full response preserving headers/config/subtitles; validates positive episode and returned episodeNumber/tracks | +| `client.source_document(id, episode, title)` | `io::Result`; fresh validated direct `ssub/sun` HLS/VTT projection for Terebi | +| `Identity::new(id, episode)`, `Identity::from_key(key)` | Validated catalog namespace/episode identity; `key()` is `miruro::` | -The wrapper does not choose providers/languages or implement a player. It preserves JSON metadata rather than freezing every deployment-specific field into a second schema. IDs/slugs are validated as nonempty ASCII alphanumeric/hyphen/underscore path segments. HTTP failures remain errors (`PermissionDenied` for 401/403); HTML and unexpected Content-Types are rejected, not converted to empty results. No tokenized URLs appear in its error messages. Successful bodies are limited to 2 MiB. +The raw methods preserve JSON metadata rather than freezing every deployment-specific field into a second schema. `source_document` deliberately selects only direct Sun soft-subtitle playback; it does not guess another provider or implement a player. IDs/slugs are validated as nonempty ASCII alphanumeric/hyphen/underscore path segments. HTTP failures remain errors (`PermissionDenied` for 401/403); HTML and unexpected Content-Types are rejected, not converted to empty results. No tokenized URLs appear in its error messages. Successful bodies are limited to 2 MiB. Its private route decoder resolves only the needed detail value and episode promise, using separate value tables and promise IDs. Unrelated sidebar promises are not returned. It supports the JSON-shaped tables verified on this path, **not arbitrary devalue**: tagged special types and negative sentinels are explicit errors, never silently coerced to null. Out-of-range references, rejected/missing/duplicate episode chunks, cycles/excessive depth and decoded-size amplification are rejected. Depth is bounded to 64 and expanded values share a 2 MiB size budget. These are wrapper safety limits, not Miruro server limits. @@ -425,16 +427,75 @@ The preceding scratch probe also reported HTTP 200 for search (`application/json The [example](../examples/miruro_probe.rs) deliberately uses the documented title and `ssub/sun` provider, and limits resource requests to HTTPS `*.keeply.top` without redirects. It fails explicitly if that sample disappears or changes instead of silently choosing a different provider. It fetches no nested playlist, media segment, key, sprite or download page, and saves no tokens/bodies. This is an explicit network smoke command, not a network-dependent unit test. -Verification: `cargo test --locked` passed all 164 tests, including the new episode-promise regression; `cargo clippy --locked --all-targets -- -D warnings` passed; `rustfmt --edition 2024 --check src/lib.rs src/miruro.rs examples/miruro_probe.rs` passed. The decoder regression covers independent tables, out-of-order unrelated chunks, preserved episode/absolute numbers and nulls, unresolved/rejected promises, bad references/cycles, unsupported tags and route redirects. +Wrapper-extraction verification, before TUI integration: `cargo test --locked` passed all 164 tests, including the episode-promise regression; `cargo clippy --locked --all-targets -- -D warnings` passed; `rustfmt --edition 2024 --check src/lib.rs src/miruro.rs examples/miruro_probe.rs` passed. The decoder regression covers independent tables, out-of-order unrelated chunks, preserved episode/absolute numbers and nulls, unresolved/rejected promises, bad references/cycles, unsupported tags and route redirects. -## Open questions before TUI/player integration +## Tubular → Terebi integration + +`tubular --miruro "apothecary"` starts the permanent Miruro search tab. 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. + +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. + +### Source document and launch + +`Client::source_document` chooses the first nonempty server/first direct HLS stream from the returned `ssub` track's `sun` provider. It requires native playback, `proxy:false`, and no enabled HLS host/query rewriting. Missing/unsupported sources are errors; no alternate language/provider, embed, proxy or refresh fallback is added. + +| Terebi source-v1 field | Projection | +| --- | --- | +| `url` | Selected stream URL, unchanged, including its query tokens | +| `headers` | Selected server map, without trimming or rewriting | +| `identity` | `{provider:"miruro", media_id:, episode:}`; not slug/external ID/absolute number/array index/playback provider | +| `title` | Episode display title | +| `subtitles` | Selected Sun provider's VTT tracks; other external formats excluded | +| Track `url`, `language`, `label`, `default` | Returned `file` and subtitle metadata | +| Track `headers` | Explicit selected Sun server map, verified for Sun resources only; no general inheritance rule | + +Projection validates HTTP(S) resource URLs, header syntax/values, case-insensitive duplicate headers, identities, control characters, 32-track/64-header limits and at most one default subtitle. Session creation also enforces the 1 MiB serialized source-document limit. Miruro media IDs allow at most 512 ASCII letters/digits/`-`/`_`; episodes range from 1 through 2147483647. + +`State::miruro_session` binds the source identity to the selected episode, creates a unique private session directory (`0700`), writes immutable `source.json` (`0600`) and metadata/identity snapshots, and returns a **nonexistent** progress destination. `PlayerLaunch::start_source` passes absolute filesystem paths, not resource URLs/header values: + +```sh +terebi --new-instance --source-file '/private/session/source.json' \ + --progress-file '/private/session/progress.jsonl' +``` + +Terebi exclusively creates the report (`0600`). CLI exit acknowledges only launch acceptance, not successful media opening or completion. Tubular retains source/report files across launcher exit, final events, and Tubular shutdown because the independent GUI may still need them. The existing 4,096-session cap and explicit inactive-session cleanup apply; close the corresponding players before removing their session directories. Private source documents contain expiring URLs/headers; watched/collection state contains only stable identity and display metadata. + +Terebi's implemented transport preserves headers on master/variant/segment/key/init requests and probes Sun's image-suffixed media/subtitle resources by content/declared VTT format. All media redirects and foreign-origin HLS resources are rejected; subtitle redirects must stay on their initial origin. Terebi does not refresh expired sources. These restrictions remain visible errors, not silent provider switches. + +### Progress and collections + +Miruro JSONL reports use version 2: + +```json +{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"opaque_ID","episode":1},"position_seconds":95.0,"duration_seconds":100.0,"completion_eligible":true} +``` + +Tubular validates the version, namespace, media ID, episode, event and numeric fields, then compares the complete identity against that launch's private session marker. Cross-episode/provider/media records cannot admit or complete another episode. YouTube's canonical `media_url` and version-1 validation remain supported, including its existing unknown-field tolerance. + +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. + +### Integration verification + +The actual Tubular terminal workflow was exercised with isolated `$XDG_CONFIG_HOME` state and the updated Terebi developer bundle selected by `--player`: autocomplete returned eight titles; the selected title returned two episodes; episode 1 was saved to both collections, freshly resolved, and launched through `State::miruro_session` / `PlayerLaunch::start_source`. + +The private source document retained Origin/Referer headers and nine VTT tracks with English declared default. Session mode `0700` and source/report modes `0600` were checked without logging resource URLs or header values. Actual Terebi v2 records matched the selected opaque ID/episode and acknowledged position advanced from about 7 to 141 seconds, with positive duration and completion eligibility. Tubular admitted that identity to its visible Currently watching list and persisted source-free collection metadata. + +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. + +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. + +## Remaining research questions - Will this same native client remain accessible on other networks/deployments? Its sampled path passed; local curl previously failed, and no workaround is implemented. - Is there a stable plain metadata/episode API? The wrapper currently owns the bounded JSON-shaped SvelteKit decoder for the observed deployment. - What are server-side filter/enum constraints, multi-value semantics, cursor lifetime, page-size-cookie limits, numeric-ID lookups and slug behavior? - What happens for unavailable episodes, empty tracks, provider failures, bad cursors, rejected promise chunks and syntactically valid missing IDs? -- Which headers/config transforms are required per provider, and can Terebi propagate them to HLS playlists/segments/keys and external subtitles? -- Are direct streams usable end-to-end, including rendition/segment access and seeking? Only master/subtitle/thumbnail retrieval was verified. +- Which headers/config transforms are required by providers other than the verified direct Sun path? +- Will other streams require redirects or cross-origin HLS resources that Terebi deliberately rejects? The Terebi team's handoff exercised direct Sun native decode, subtitles and acknowledged seeks, not every provider or comprehensive visible synchronization. - What are seeded discovery authentication requirements, rate limits, token expiry/IP binding and content-use permissions? Reachable internal endpoints do not imply a supported third-party integration or permission to redistribute content. Hashed JavaScript URLs below identify the inspected deployment, not stable versions. For later research, re-read the homepage's entry-script imports and [route map][routes] to locate the current bundles; do not hardcode these hashes in the wrapper. diff --git a/src/main.rs b/src/main.rs index 5b6ab85..6bdeef2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,14 @@ -//! A Ratatui YouTube browser backed by yt-dlp, with playback handed to Terebi. +//! A Ratatui YouTube/Miruro browser 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. //! Always start in normal mode; a CLI query searches YouTube automatically. Press i to edit. +//! `--miruro apothecary` starts the persistent Miruro tab; Enter drills into actual returned episodes. +//! 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. +//! Route titles follow Miruro's Unicode NFD slug helper; unrepresentable titles report an explicit error. //! Category and combined-category queries search their channels' full histories; other views search YouTube. //! Insert: Enter submits a trimmed changed query; clearing a category query restores uploads. //! Esc keeps the draft without submitting; Search and each category retain independent drafts and pages. @@ -52,7 +57,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 and Feed are permanent tabs. Normal-mode c opens the saved-category browser. +//! Search, Feed, and Miruro 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. @@ -113,6 +118,7 @@ mod config; mod keys; +mod miruro_browser; mod thumbnail; mod video_state; mod youtube; @@ -148,11 +154,11 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[derive(Parser)] #[command( version, - about = "Browse YouTube 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.\nRequires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi 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.\nAlways start in NORMAL. A CLI query is searched 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. 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 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\"" + 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.\nAlways start in NORMAL. A CLI query is searched 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\"" )] struct Options { - /// Terebi CLI executable; must support --new-instance, --url, and --progress-file. + /// Terebi CLI executable; Miruro also requires --source-file support. #[arg(long, default_value = "terebi")] player: OsString, @@ -160,7 +166,11 @@ struct Options { #[arg(long, conflicts_with = "query")] default_config: bool, - /// Optional initial YouTube search. + /// Start in the Miruro catalog (autocomplete, eight results). + #[arg(long)] + miruro: bool, + + /// Optional initial search (YouTube, or Miruro with --miruro). query: Option, } @@ -186,6 +196,8 @@ enum Mode { enum ViewSource { Search, Feed, + MiruroSearch, + MiruroEpisodes { media_id: String, slug: String }, Category(String), CategoryGroup(Vec), Channel(String), @@ -202,7 +214,12 @@ impl ViewSource { group: &'a [String], ) -> &'a [String] { match self { - Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) | Self::Watching => &[], + Self::Search + | Self::MiruroSearch + | Self::MiruroEpisodes { .. } + | Self::WatchLater(_) + | Self::SavedVideos(_) + | Self::Watching => &[], Self::Feed => feed, Self::Category(name) => &config.categories[name], Self::CategoryGroup(_) => group, @@ -927,10 +944,109 @@ 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); @@ -965,13 +1081,15 @@ impl App { View::new("Search".into(), ViewSource::Search), View::new("Feed".into(), ViewSource::Feed), ]; - views[0].query = options.query.take().unwrap_or_default(); views.extend( config .categories .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)); + views[initial_index].query = options.query.take().unwrap_or_default(); Self { options, mode: Mode::Normal, @@ -982,7 +1100,7 @@ impl App { key_hints, feed_channels, views, - view_index: 0, + view_index: initial_index, view_history: Vec::new(), forward_history: Vec::new(), category_dialog: None, @@ -1001,7 +1119,16 @@ impl App { description_viewport: None, job: None, player_launch: None, - status: "Enter a query to search YouTube.".into(), + 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(), } } @@ -1010,9 +1137,9 @@ impl App { .iter() .enumerate() .filter(|(_, view)| match &view.source { - ViewSource::Search | ViewSource::Feed => true, + ViewSource::Search | ViewSource::Feed | ViewSource::MiruroSearch => true, ViewSource::Category(name) => self.config.pinned_categories.contains(name), - ViewSource::CategoryGroup(_) | ViewSource::Channel(_) + ViewSource::MiruroEpisodes { .. } | ViewSource::CategoryGroup(_) | ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching => false, }) .chain(self.temporary_view.as_ref().and_then(|source| { @@ -1268,6 +1395,10 @@ 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; @@ -1323,7 +1454,7 @@ impl App { fn query_view_index(&self) -> usize { if matches!( self.views[self.view_index].source, - ViewSource::Category(_) | ViewSource::CategoryGroup(_) + ViewSource::Category(_) | ViewSource::CategoryGroup(_) | ViewSource::MiruroSearch ) { self.view_index } else { @@ -1335,6 +1466,14 @@ 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; @@ -1343,6 +1482,7 @@ impl App { view.submitted_query = query.to_owned(); view.reset(); self.job = None; + self.cancel_miruro(); } self.switch_view(index); } @@ -1387,6 +1527,7 @@ impl App { if matches!( source, ViewSource::CategoryGroup(_) + | ViewSource::MiruroEpisodes { .. } | ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) @@ -1397,6 +1538,7 @@ impl App { } if index != self.view_index { self.job = None; + self.cancel_miruro(); self.view_index = index; } self.close_preview(); @@ -1411,6 +1553,7 @@ impl App { fn load_view(&mut self) { self.job = None; + self.cancel_miruro(); self.close_preview(); if matches!( self.views[self.view_index].source, @@ -1425,7 +1568,7 @@ impl App { } fn load_page(&mut self) { - if self.job.is_some() { + if self.job.is_some() || self.miruro_loading { return; } if matches!( @@ -1436,6 +1579,22 @@ 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 { @@ -1513,6 +1672,21 @@ 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 @@ -1672,6 +1846,10 @@ 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(()) => { @@ -1814,6 +1992,10 @@ 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( @@ -1829,6 +2011,9 @@ 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( @@ -1855,6 +2040,7 @@ 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) })); @@ -1890,6 +2076,7 @@ impl App { fn refresh(&mut self) { self.close_preview(); + self.cancel_miruro(); match config::load(&self.config_path) { Ok(config) => { self.thumbnail.clear_cache(); @@ -2161,6 +2348,8 @@ impl App { matches!( view.source, ViewSource::CategoryGroup(_) + | ViewSource::MiruroSearch + | ViewSource::MiruroEpisodes { .. } | ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) @@ -2188,6 +2377,12 @@ 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; @@ -2454,6 +2649,10 @@ 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()), @@ -2483,6 +2682,30 @@ 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, @@ -2513,6 +2736,7 @@ 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); } @@ -2855,7 +3079,11 @@ impl App { .selected() .and_then(|index| self.views[self.view_index].videos.get(index)) { - self.thumbnail.select(&video.id); + if !miruro_browser::is_miruro(video) { + self.thumbnail.select(&video.id); + } else { + self.thumbnail.cancel(); + } } false } @@ -2919,6 +3147,7 @@ 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() } @@ -2999,7 +3228,7 @@ impl App { ViewSource::CategoryGroup(_) => self.key_hints.group_save.as_str(), _ => "", }, - self.job.is_some().then_some(spinner), + (self.job.is_some() || self.miruro_loading).then_some(spinner), ))) .row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)) .highlight_symbol("> "); @@ -3545,7 +3774,7 @@ impl App { SetCursorStyle::SteadyBlock )?; self.thumbnail.query_terminal(); - if !self.views[0].query.trim().is_empty() { + if !self.views[self.view_index].query.trim().is_empty() { self.search(); } let started = Instant::now(); @@ -3658,6 +3887,112 @@ fn main() -> io::Result<()> { mod tests { use super::*; + #[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), @@ -3973,12 +4308,13 @@ mod tests { let mut group = View::new("Combined".into(), ViewSource::CategoryGroup(vec![])); group.loaded = true; app.views.push(group); - app.view_index = 2; + let group_index = app.views.len() - 1; + app.view_index = group_index; rename_press(&mut app, KeyCode::Char('P')); rename_press(&mut app, KeyCode::Char('/')); rename_press(&mut app, KeyCode::Char('F')); assert_eq!(app.category_name_input.as_ref().unwrap().name, "/F"); - assert_eq!(app.view_index, 2); + assert_eq!(app.view_index, group_index); } #[test] @@ -4240,7 +4576,7 @@ mod tests { combined, ViewSource::CategoryGroup(vec!["Alpha".into(), "Beta".into()]) ); - assert_eq!(app.visible_views().count(), 3); + assert_eq!(app.visible_views().count(), 4); rename_press(&mut app, KeyCode::Char('c')); assert_eq!( app.browser @@ -5052,7 +5388,7 @@ mod tests { Some(ViewSource::Category("Zeta".into())) ); assert!(app.config.pinned_categories.is_empty()); - assert_eq!(app.visible_views().count(), 3); + assert_eq!(app.visible_views().count(), 4); assert_eq!( app.visible_views().last().unwrap().1.source, ViewSource::Category("Zeta".into()) @@ -5861,7 +6197,7 @@ mod tests { ViewSource::SavedVideos(None) ); assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - assert_eq!(app.visible_views().count(), 3); + 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); @@ -7244,6 +7580,7 @@ tags.cancel = "z" ViewSource::Search, ViewSource::Feed, ViewSource::Category("@Alpha".into()), + ViewSource::MiruroSearch, ViewSource::Channel("beta".into()) ] ); @@ -7261,7 +7598,7 @@ tags.cancel = "z" app.views[app.view_index].source, ViewSource::Category("Temporary".into()) ); - assert_eq!(app.visible_views().count(), 4); + assert_eq!(app.visible_views().count(), 5); assert!( !app.visible_views() .any(|(_, view)| matches!(view.source, ViewSource::Channel(_))) @@ -7341,7 +7678,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(), 2); + assert_eq!(app.views.len(), 3); 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)); @@ -7746,7 +8083,8 @@ tags.cancel = "z" .map(|(_, view)| view.name.clone()) .collect::>() }; - assert_eq!(visible(&app), ["Search", "Feed"]); + assert_eq!(visible(&app), ["Search", "Feed", "Miruro"]); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(app.view_index, 0); @@ -7754,29 +8092,31 @@ 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", "Beta"]); + assert_eq!(visible(&app), ["Search", "Feed", "Miruro", "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", "Gamma"]); + assert_eq!(visible(&app), ["Search", "Feed", "Miruro", "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", "Gamma"]); + assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Miruro", "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"]); + assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Miruro"]); app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); + assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Miruro"]); assert!(app.browser.is_none()); app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))); @@ -8244,8 +8584,9 @@ tags.cancel = "z" .insert("Two".into(), vec!["https://www.youtube.com/@two".into()]); app.views .push(View::new("Two".into(), ViewSource::Category("Two".into()))); + let category = app.views.len() - 1; app.views[0].loaded = true; - for index in [1, 2] { + for index in [1, category] { app.view_index = index; finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true); } @@ -8253,10 +8594,10 @@ tags.cancel = "z" app.views[1].prefetch_requested = true; app.views[1].feed_requests = app.views[1].feed_cursor.plan(1).unwrap(); let cancelled = app.views[1].feed_requests.clone(); - app.switch_view(2); + app.switch_view(category); finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true); assert_eq!( - app.views[2].feed_cursor.plan(1).unwrap()[0].start, + app.views[category].feed_cursor.plan(1).unwrap()[0].start, PAGE_SIZE * 2 + 1 ); app.back_view(); @@ -8265,10 +8606,10 @@ tags.cancel = "z" assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), cancelled); assert!(app.views[1].prefetch_requested); app.forward_view(); - assert_eq!(app.view_index, 2); - assert!(!app.views[2].prefetch_requested); + assert_eq!(app.view_index, category); + assert!(!app.views[category].prefetch_requested); assert_eq!( - app.views[2].feed_cursor.plan(1).unwrap()[0].start, + app.views[category].feed_cursor.plan(1).unwrap()[0].start, PAGE_SIZE * 2 + 1 ); } @@ -9003,7 +9344,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(), 2); + assert_eq!(app.views.len(), 3); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL)); assert_eq!(app.view_index, 1); assert!(app.views[1].loaded); @@ -9011,9 +9352,11 @@ 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.view_index, 1); + assert_eq!(app.views[app.view_index].source, ViewSource::MiruroSearch); assert!(app.job.is_none()); } diff --git a/src/miruro.rs b/src/miruro.rs index fb88e71..d644343 100644 --- a/src/miruro.rs +++ b/src/miruro.rs @@ -1,10 +1,15 @@ //! Blocking access to the deployed Miruro catalog and source API. //! -//! See `docs/miruro-api.md`. Source URLs are ephemeral; retain server headers and -//! provider configuration for playback. This module does not select a provider, -//! bypass protection, decode iframe embeds, or integrate with a player. -use std::{io, mem::size_of, time::Duration}; +//! See `docs/miruro-api.md`. Source URLs are ephemeral. Playback projection is +//! limited to direct Sun soft-subtitle sources; no proxy, iframe or retry fallback. +use std::{ + collections::{BTreeMap, HashSet}, + io, + mem::size_of, + time::Duration, +}; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; const BASE: &str = "https://barelystarted.miruro.tv"; @@ -20,6 +25,262 @@ pub struct Detail { pub episodes: Vec, } +/// Stable catalog identity, never a CDN address or playback-provider name. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Identity { + pub provider: String, + pub media_id: String, + pub episode: u32, +} + +impl Identity { + pub fn new(media_id: String, episode: u32) -> io::Result { + let identity = Self { + provider: "miruro".into(), + media_id, + episode, + }; + identity.validate()?; + Ok(identity) + } + + pub fn validate(&self) -> io::Result<()> { + if self.provider != "miruro" + || self.media_id.len() > 512 + || self.episode == 0 + || self.episode > i32::MAX as u32 + { + return Err(invalid("Invalid Miruro episode identity")); + } + validate_segment(&self.media_id) + } + + pub fn key(&self) -> String { + format!("miruro:{}:{}", self.media_id, self.episode) + } + + pub fn from_key(key: &str) -> io::Result { + let (media_id, episode) = key + .strip_prefix("miruro:") + .and_then(|rest| rest.split_once(':')) + .ok_or_else(|| invalid("Invalid Miruro episode key"))?; + if episode.starts_with('0') || !episode.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid("Noncanonical Miruro episode key")); + } + Self::new( + media_id.into(), + episode + .parse() + .map_err(|_| invalid("Invalid Miruro episode number"))?, + ) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct Subtitle { + pub url: String, + pub format: String, + pub language: String, + pub label: String, + pub default: bool, + pub headers: BTreeMap, +} + +#[derive(Clone, Debug, Serialize)] +pub struct SourceDocument { + pub version: u32, + pub url: String, + pub headers: BTreeMap, + pub identity: Identity, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub subtitles: Vec, +} + +impl SourceDocument { + pub fn validate(&self) -> io::Result<()> { + if self.version != 1 + || self.subtitles.len() > 32 + || self.subtitles.iter().filter(|track| track.default).count() > 1 + { + return Err(invalid( + "Invalid source document version or subtitle limits", + )); + } + if self + .title + .as_ref() + .is_some_and(|title| title.chars().any(char::is_control)) + { + return Err(invalid("Invalid source title")); + } + self.identity.validate()?; + validate_url(&self.url)?; + validate_headers(&self.headers)?; + for track in &self.subtitles { + if track.format != "vtt" { + return Err(invalid("Only external VTT subtitles are supported")); + } + if track.label.chars().any(char::is_control) + || track.language.chars().any(char::is_control) + { + return Err(invalid("Invalid subtitle label or language")); + } + validate_url(&track.url)?; + validate_headers(&track.headers)?; + } + Ok(()) + } + + /// No provider/language fallback: take the first direct ssub/Sun server. + pub fn from_sources(sources: &Value, identity: Identity, title: &str) -> io::Result { + identity.validate()?; + if sources["episodeNumber"].as_u64() != Some(u64::from(identity.episode)) { + return Err(invalid( + "Resolved source episode does not match its identity", + )); + } + let config = &sources["config"]["sun"]; + if config["player"] != "native" + || config["proxy"] != false + || config["hls"]["segments"]["enabled"] == true + || config["hls"]["query"]["enabled"] == true + { + return Err(invalid("Sun requires unsupported proxy or HLS rewriting")); + } + let provider = sources["tracks"] + .as_array() + .and_then(|tracks| tracks.iter().find(|track| track["track"] == "ssub")) + .and_then(|track| track["providers"].as_array()) + .and_then(|providers| { + providers + .iter() + .find(|provider| provider["provider"] == "sun") + }) + .ok_or_else(|| invalid("Direct Sun soft-subtitle source is unavailable"))?; + let server = provider["servers"] + .as_array() + .and_then(|servers| { + servers.iter().find(|server| { + server["streams"] + .as_array() + .is_some_and(|streams| !streams.is_empty()) + }) + }) + .ok_or_else(|| invalid("Direct Sun stream is unavailable"))?; + let stream = &server["streams"][0]; + if !matches!( + stream["format"].as_str(), + Some("hls" | "m3u8" | "application/vnd.apple.mpegurl" | "application/x-mpegurl") + ) || !stream["embed"].is_null() + { + return Err(invalid("Selected Sun stream is not direct HLS")); + } + let headers = source_headers(&server["headers"])?; + let mut subtitles = Vec::new(); + for subtitle in provider["subtitles"] + .as_array() + .ok_or_else(|| invalid("Missing Sun subtitle array"))? + { + if subtitle["format"] != "vtt" { + continue; + } + if subtitles.len() == 32 { + return Err(invalid("Source subtitle limit exceeded")); + } + subtitles.push(Subtitle { + url: source_string(subtitle, "file")?, + format: "vtt".into(), + language: source_string(subtitle, "language")?, + label: source_string(subtitle, "label")?, + default: subtitle["default"] + .as_bool() + .ok_or_else(|| invalid("Invalid subtitle default flag"))?, + // Verified for direct Sun only. These are explicit resource headers, + // not an inheritance rule for other providers. + headers: headers.clone(), + }); + } + let document = Self { + version: 1, + url: source_string(stream, "url")?, + headers, + identity, + title: Some(title.into()), + subtitles, + }; + document.validate()?; + Ok(document) + } +} + +fn source_string(value: &Value, field: &str) -> io::Result { + value + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| invalid("Missing or invalid resolved source field")) +} + +fn source_headers(value: &Value) -> io::Result> { + if value.is_null() { + return Ok(BTreeMap::new()); + } + let headers = value + .as_object() + .ok_or_else(|| invalid("Invalid source header map"))?; + if headers.len() > 64 { + return Err(invalid("Source header limit exceeded")); + } + headers + .iter() + .map(|(key, value)| { + Ok(( + key.clone(), + value + .as_str() + .ok_or_else(|| invalid("Invalid source header value"))? + .into(), + )) + }) + .collect() +} + +fn validate_url(url: &str) -> io::Result<()> { + let uri: ureq::http::Uri = url.parse().map_err(|_| invalid("Invalid source URL"))?; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || uri.host().is_none_or(str::is_empty) + || uri + .authority() + .is_some_and(|authority| authority.as_str().contains('@')) + || url.chars().any(char::is_control) + { + return Err(invalid( + "Source URLs must be absolute HTTP(S) without credentials", + )); + } + Ok(()) +} + +fn validate_headers(headers: &BTreeMap) -> io::Result<()> { + if headers.len() > 64 { + return Err(invalid("Source header limit exceeded")); + } + let mut names = HashSet::with_capacity(headers.len()); + for (name, value) in headers { + let name = ureq::http::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| invalid("Invalid source header name"))?; + if !names.insert(name) + || value.chars().any(char::is_control) + || ureq::http::HeaderValue::from_str(value).is_err() + { + return Err(invalid("Invalid or duplicate source header")); + } + } + Ok(()) +} + impl Default for Client { fn default() -> Self { Self { @@ -98,6 +359,16 @@ impl Client { Ok(value) } + pub fn source_document( + &self, + id: &str, + episode: u32, + title: &str, + ) -> io::Result { + let identity = Identity::new(id.into(), episode)?; + SourceDocument::from_sources(&self.sources(id, episode)?, identity, title) + } + fn get(&self, path: &str, query: &[(&str, &str)], types: &[&str]) -> io::Result { let mut response = self .agent @@ -277,6 +548,80 @@ fn expand(table: &[Value], index: usize, depth: usize, budget: &mut usize) -> io mod tests { use super::*; + #[test] + fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() { + let sources = serde_json::json!({ + "episodeNumber": 1, + "config": { "sun": { "player": "native", "proxy": false } }, + "tracks": [{ "track": "ssub", "providers": [{ + "provider": "sun", + "servers": [{ + "headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" }, + "streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }] + }], + "subtitles": [{ + "file": "https://vault.keeply.top/english.jpeg", "format": "vtt", + "language": "en", "label": "English", "default": true + }] + }] }] + }); + let identity = Identity::new("catalog".into(), 1).unwrap(); + let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap(); + assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity); + assert_ne!( + Identity::new("catalog".into(), 2).unwrap().key(), + source.identity.key() + ); + for key in [ + "miruro:catalog:01", + "miruro:catalog:0", + "miruro:catalog:2147483648", + "miruro:catalog/:1", + ] { + assert!(Identity::from_key(key).is_err()); + } + for (pointer, value) in [ + ("/episodeNumber", serde_json::json!(49)), + ("/config/sun/proxy", serde_json::json!({"segments": true})), + ( + "/tracks/0/providers/0/servers/0/streams/0/url", + serde_json::json!("file:///secret"), + ), + ( + "/tracks/0/providers/0/servers/0/streams/0/embed", + serde_json::json!({"url": "https://embed.example"}), + ), + ( + "/tracks/0/providers/0/servers/0/headers/origin", + serde_json::json!("secret"), + ), + ( + "/tracks/0/providers/0/servers/0/headers/Referer", + serde_json::json!("secret\r\nX-Injected: true"), + ), + ] { + let mut invalid_sources = sources.clone(); + if let Some(field) = invalid_sources.pointer_mut(pointer) { + *field = value; + } else { + invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] = + value; + } + let error = + SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1") + .unwrap_err(); + assert!(!error.to_string().contains("secret")); + } + let mut invalid_source = source; + invalid_source + .subtitles + .push(invalid_source.subtitles[0].clone()); + assert!(invalid_source.validate().is_err()); + invalid_source.subtitles[1].default = false; + invalid_source.subtitles[1].format = "srt".into(); + assert!(invalid_source.validate().is_err()); + } + #[test] fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() { let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#; diff --git a/src/miruro_browser.rs b/src/miruro_browser.rs new file mode 100644 index 0000000..57d6c68 --- /dev/null +++ b/src/miruro_browser.rs @@ -0,0 +1,276 @@ +use crate::youtube::Video; +use serde_json::Value; +use std::{ + io, + sync::mpsc::{self, Receiver, SyncSender}, + thread, +}; +use tubular::miruro::{Client, Detail, Identity, SourceDocument}; +use unicode_normalization::UnicodeNormalization; + +pub enum Request { + Search(String), + Episodes { media_id: String, slug: String }, + Source(Video), +} +pub enum Response { + Rows(Vec<(Video, String)>), + Source(Video, Box), +} + +// One running request, no queued network requests. The UI retains only its newest intent. +pub struct Worker { + sender: SyncSender<(u64, Request)>, + receiver: Receiver<(u64, io::Result)>, + pub busy: bool, + generation: u64, +} +impl Worker { + pub fn new() -> Self { + let (sender, requests) = mpsc::sync_channel::<(u64, Request)>(1); + let (results, receiver) = mpsc::sync_channel(1); + thread::spawn(move || { + let client = Client::default(); + while let Ok((generation, request)) = requests.recv() { + let result = match request { + Request::Search(query) => client + .search(&query) + .and_then(catalog_rows) + .map(Response::Rows), + Request::Episodes { media_id, slug } => client + .detail(&media_id, &slug) + .and_then(|detail| episode_rows(&media_id, detail)) + .map(Response::Rows), + Request::Source(video) => Identity::from_key(&video.id) + .and_then(|id| { + client.source_document(&id.media_id, id.episode, &video.title) + }) + .map(|source| Response::Source(video, Box::new(source))), + }; + if results.send((generation, result)).is_err() { + break; + } + } + }); + Self { + sender, + receiver, + busy: false, + generation: 0, + } + } + pub fn submit(&mut self, generation: u64, request: Request) -> io::Result<()> { + if self.busy { + return Err(io::Error::other("Miruro worker is busy")); + } + self.sender + .try_send((generation, request)) + .map_err(|_| io::Error::other("Miruro worker unavailable"))?; + self.busy = true; + self.generation = generation; + Ok(()) + } + pub fn poll(&mut self) -> Option<(u64, io::Result)> { + match self.receiver.try_recv() { + Ok(result) => { + self.busy = false; + Some(result) + } + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) if self.busy => { + self.busy = false; + Some(( + self.generation, + Err(io::Error::other("Miruro worker stopped")), + )) + } + Err(_) => None, + } + } +} + +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) +} +fn title(media: &Value) -> io::Result { + let get = |language| { + media + .get("title") + .and_then(|v| v.get(language)) + .and_then(Value::as_str) + .map(|text| text.trim_matches(js_whitespace)) + .filter(|s| !s.is_empty()) + }; + let selected = match (get("english"), get("romaji")) { + (Some(a), Some(b)) => { + if a.encode_utf16().count() <= b.encode_utf16().count() { + a + } else { + b + } + } + (Some(a), None) | (None, Some(a)) => a, + _ => media + .get("title") + .and_then(|value| value.get("native")) + .and_then(Value::as_str) + .or_else(|| media.get("id").and_then(Value::as_str)) + .ok_or_else(|| invalid("Miruro title is missing"))?, + }; + Ok(selected.to_owned()) +} +// ECMAScript \s/trim, not Rust's Unicode whitespace (which includes U+0085). +fn js_whitespace(ch: char) -> bool { + matches!(ch, '\u{0009}'..='\u{000d}' | ' ' | '\u{00a0}' | '\u{1680}' + | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' + | '\u{205f}' | '\u{3000}' | '\u{feff}') +} +pub fn slug(title: &str) -> io::Result { + let cleaned: String = title + .nfd() + .filter(|ch| !('\u{0300}'..='\u{036f}').contains(ch)) + .flat_map(char::to_lowercase) + .filter(|ch| { + ch.is_ascii_lowercase() || ch.is_ascii_digit() || js_whitespace(*ch) || *ch == '-' + }) + .collect(); + let mut slug = String::new(); + for ch in cleaned.trim_matches(js_whitespace).chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + } else if (js_whitespace(ch) || ch == '-') && !slug.ends_with('-') { + slug.push('-'); + } + } + if slug.is_empty() { + Err(invalid("Miruro title has no representable route slug")) + } else { + Ok(slug) + } +} +fn video(id: String, title: String, date: Option) -> Video { + Video { + id, + title, + channel: Some("Miruro".into()), + channel_id: None, + uploader_id: None, + timestamp: None, + publication_date: date, + } +} +pub fn catalog_rows(items: Vec) -> io::Result> { + 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, + )) + }) + .collect() +} +pub fn episode_rows(media_id: &str, detail: Detail) -> io::Result> { + let series = title(&detail.media)?; + let mut seen = std::collections::HashSet::new(); + detail + .episodes + .into_iter() + .map(|episode| { + let number = episode + .get("episode_number") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| invalid("Miruro episode number is invalid"))?; + let identity = Identity::new(media_id.to_owned(), number)?; + if !seen.insert(number) { + return Err(invalid("Miruro returned duplicate episode numbers")); + } + let label = episode.get("title").and_then(Value::as_str).unwrap_or(""); + let title = format!( + "{series} · Episode {number}{}", + if label.is_empty() { + String::new() + } else { + format!(" · {label}") + } + ); + let description = episode + .get("synopsis") + .and_then(Value::as_str) + .unwrap_or("Episode metadata unavailable.") + .to_owned(); + let date = episode + .get("aired_on") + .and_then(Value::as_str) + .map(str::to_owned); + Ok((video(identity.key(), title, date), description)) + }) + .collect() +} + +#[cfg(test)] +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()); + assert!(slug("薬屋").is_err()); + assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title"); + assert_eq!(slug(" --Title-- ").unwrap(), "-title-"); + assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu"); + assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe"); + assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i"); + assert_eq!( + slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(), + "a-b-c" + ); + assert_eq!(slug("A\u{0085}B").unwrap(), "ab"); + assert!(slug("!!薬屋!!").is_err()); + assert_eq!( + title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(), + "AB" + ); + assert_eq!( + title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(), + "BC" + ); + assert_eq!( + title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(), + "" + ); + } +} diff --git a/src/video_state.rs b/src/video_state.rs index 505c3fe..dd84496 100644 --- a/src/video_state.rs +++ b/src/video_state.rs @@ -10,11 +10,14 @@ use serde::Deserialize; use toml_edit::{Array, ArrayOfTables, DocumentMut, Table, value}; use crate::youtube::{Video, valid_video_id, validate_video}; +use tubular::miruro::{Identity, SourceDocument}; const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024; const MAX_LINE: usize = 16 * 1024; const STREAM_BYTES: usize = 16 * 1024; const MAX_STREAMS: usize = 4096; +const MAX_SOURCE_BYTES: usize = 1024 * 1024; +const MAX_IDENTITY_BYTES: u64 = 7 + 512 + 1 + 10; static NEXT_NAME: AtomicU64 = AtomicU64::new(0); fn invalid(message: impl Into) -> io::Error { @@ -239,6 +242,26 @@ impl State { Ok(path) } + pub fn miruro_session( + &mut self, + video: &Video, + source: &SourceDocument, + ) -> io::Result<(PathBuf, PathBuf)> { + source.validate()?; + if Identity::from_key(&video.id)? != source.identity { + return Err(invalid("Source identity must match the playback episode")); + } + let contents = serde_json::to_vec(source).map_err(|e| invalid(e.to_string()))?; + if contents.len() > MAX_SOURCE_BYTES { + return Err(invalid("Playback source exceeds the 1 MiB limit")); + } + let progress_file = self.progress_file(video)?; + let source_file = progress_file.with_file_name("source.json"); + atomic_write(&source_file, None, &contents)?; + register_identity(&progress_file, &video.id)?; + Ok((source_file, progress_file)) + } + pub fn poll_progress(&mut self) -> io::Result { let mut error = self.discover().err(); let count = self.streams.len().min(16); @@ -372,6 +395,23 @@ fn validate_snapshot(video: &mut Video, require_channel: bool) -> io::Result<()> )); } } + if video.id.starts_with("miruro:") { + Identity::from_key(&video.id)?; + if video.title.trim().is_empty() || video.title.chars().any(char::is_control) { + return Err(invalid( + "Miruro episode title must be nonempty and contain no controls", + )); + } + if video.channel.as_deref() != Some("Miruro") + || video.channel_id.is_some() + || video.uploader_id.is_some() + { + return Err(invalid( + "Miruro episodes must not contain YouTube channel identities", + )); + } + return Ok(()); + } validate_video(video).map_err(invalid)?; video.publication_date = date; if let Some(handle) = &video.uploader_id @@ -448,13 +488,13 @@ fn load_watched(document: &DocumentMut) -> io::Result> { return Ok(BTreeSet::new()); }; item.as_array() - .ok_or_else(|| invalid("watched must be an array of YouTube IDs"))? + .ok_or_else(|| invalid("watched must be an array of playback identities"))? .iter() .map(|v| { let id = v .as_str() - .filter(|id| valid_video_id(id)) - .ok_or_else(|| invalid("Invalid watched YouTube ID"))?; + .filter(|id| valid_identity(id)) + .ok_or_else(|| invalid("Invalid watched playback identity"))?; Ok(id.to_owned()) }) .collect() @@ -641,8 +681,12 @@ fn private_directory(path: &Path, create: bool) -> io::Result<()> { Ok(()) } +fn valid_identity(id: &str) -> bool { + valid_video_id(id) || Identity::from_key(id).is_ok() +} + pub(crate) fn register_identity(progress_file: &Path, id: &str) -> io::Result<()> { - if !valid_video_id(id) + if !valid_identity(id) || progress_file .file_name() .is_none_or(|name| name != "progress.jsonl") @@ -674,7 +718,14 @@ pub(crate) fn register_identity(progress_file: &Path, id: &str) -> io::Result<() } #[derive(Deserialize)] -struct ProgressRecord { +#[serde(untagged)] +enum ProgressRecord { + YouTube(YouTubeProgress), + Miruro(MiruroProgress), +} + +#[derive(Deserialize)] +struct YouTubeProgress { version: u32, event: String, media_url: String, @@ -683,6 +734,17 @@ struct ProgressRecord { completion_eligible: bool, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MiruroProgress { + version: u32, + event: String, + media_identity: Identity, + position_seconds: f64, + duration_seconds: Option, + completion_eligible: bool, +} + struct ProgressStream { path: PathBuf, id: Option, @@ -722,11 +784,11 @@ impl ProgressStream { let marker = self.path.with_file_name("video-id"); let mut bytes = Vec::new(); regular_file(&marker, true)? - .take(65) + .take(MAX_IDENTITY_BYTES + 1) .read_to_end(&mut bytes)?; let id = std::str::from_utf8(&bytes) .ok() - .filter(|id| valid_video_id(id)) + .filter(|id| id.len() as u64 <= MAX_IDENTITY_BYTES && valid_identity(id)) .ok_or_else(|| invalid("Invalid video-id session marker"))?; let snapshot = self.path.with_file_name("video.toml"); let video = match regular_file(&snapshot, true) { @@ -739,7 +801,12 @@ impl ProgressStream { } collection.videos.pop() } - Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) if e.kind() == io::ErrorKind::NotFound && valid_video_id(id) => None, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return Err(invalid( + "Miruro sessions require their bound video metadata", + )); + } Err(e) => return Err(e), }; let seen_path = self.path.with_file_name("watching-seen.toml"); @@ -856,22 +923,48 @@ impl ProgressStream { ) -> io::Result<()> { let record: ProgressRecord = serde_json::from_slice(&self.partial).map_err(|e| self.record_error(e))?; - if record.version != 1 || !matches!(record.event.as_str(), "progress" | "ended" | "closed") - { - return Err( - self.record_error("expected version 1 and progress, ended, or closed event") - ); + let miruro_key; + let (id, event, position, duration, eligible) = match &record { + ProgressRecord::YouTube(record) if record.version == 1 => { + let id = record + .media_url + .strip_prefix("https://www.youtube.com/watch?v=") + .filter(|id| valid_video_id(id)) + .ok_or_else(|| self.record_error("expected canonical YouTube video URL"))?; + ( + id, + record.event.as_str(), + record.position_seconds, + record.duration_seconds, + record.completion_eligible, + ) + } + ProgressRecord::Miruro(record) if record.version == 2 => { + record + .media_identity + .validate() + .map_err(|e| self.record_error(e))?; + miruro_key = record.media_identity.key(); + ( + miruro_key.as_str(), + record.event.as_str(), + record.position_seconds, + record.duration_seconds, + record.completion_eligible, + ) + } + _ => { + return Err(self.record_error( + "expected version 1 with media_url or version 2 with media_identity", + )); + } + }; + if !matches!(event, "progress" | "ended" | "closed") { + return Err(self.record_error("expected progress, ended, or closed event")); } - let id = record - .media_url - .strip_prefix("https://www.youtube.com/watch?v=") - .filter(|id| valid_video_id(id)) - .ok_or_else(|| self.record_error("expected canonical YouTube video URL"))?; - if !record.position_seconds.is_finite() - || record.position_seconds < 0.0 - || record - .duration_seconds - .is_some_and(|d| !d.is_finite() || d <= 0.0) + if !position.is_finite() + || position < 0.0 + || duration.is_some_and(|d| !d.is_finite() || d <= 0.0) { return Err(self.record_error( "position must be finite/nonnegative and duration positive/finite or null", @@ -880,13 +973,11 @@ impl ProgressStream { if self.id.as_deref() != Some(id) { return Ok(()); } - if record.position_seconds > 0.0 && !self.seen && !watched.contains(id) { + if position > 0.0 && !self.seen && !watched.contains(id) { self.started = true; } - if record.completion_eligible - && record - .duration_seconds - .is_some_and(|d| record.position_seconds / d >= 0.95) + if eligible + && duration.is_some_and(|d| position / d >= 0.95) && !watched.contains(id) && !pending.contains(id) { @@ -955,6 +1046,50 @@ mod tests { ) } + fn miruro_video(episode: u32) -> Video { + Video { + id: Identity::new("opaque_Show-1".into(), episode) + .unwrap() + .key(), + title: format!("Example show · Episode {episode}"), + channel: Some("Miruro".into()), + channel_id: None, + uploader_id: None, + timestamp: Some(1_114_300_000), + publication_date: Some("2005-04-23".into()), + } + } + + fn miruro_source(video: &Video) -> SourceDocument { + SourceDocument { + version: 1, + url: "https://cdn.example/video.m3u8?token=private-token".into(), + headers: [("Referer".into(), "https://player.example/".into())].into(), + identity: Identity::from_key(&video.id).unwrap(), + title: Some(video.title.clone()), + subtitles: Vec::new(), + } + } + + fn miruro_record( + video: &Video, + event: &str, + eligible: bool, + position: f64, + duration: Option, + ) -> String { + serde_json::json!({ + "version": 2, + "event": event, + "media_identity": Identity::from_key(&video.id).unwrap(), + "position_seconds": position, + "duration_seconds": duration, + "completion_eligible": eligible, + }) + .to_string() + + "\n" + } + #[test] fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() { let directory = TestDirectory::new(); @@ -1565,4 +1700,348 @@ mod tests { .unwrap(); assert!(State::open(&directory.config()).is_err()); } + + #[test] + fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_in_state() { + let directory = TestDirectory::new(); + let video = miruro_video(1); + let mut state = State::open(&directory.config()).unwrap(); + for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { + state.save(kind, &video).unwrap(); + } + let source = miruro_source(&video); + let (source_file, report) = state.miruro_session(&video, &source).unwrap(); + let source_bytes = fs::read(&source_file).unwrap(); + assert_eq!(source_bytes, serde_json::to_vec(&source).unwrap()); + assert!(!report.exists()); + assert_eq!( + fs::read_to_string(report.with_file_name("video-id")).unwrap(), + video.id + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [ + &source_file, + &report.with_file_name("video-id"), + &report.with_file_name("video.toml"), + ] { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + for path in [ + report.parent().unwrap(), + &directory.0.join("playback-sessions"), + ] { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + } + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + drop(state); + // A GUI can acknowledge its final snapshot after Tubular has quit. + append( + &report, + miruro_record(&video, "closed", false, 10.0, None).as_bytes(), + ); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.poll_progress().unwrap()); + let stored = &state.videos(CollectionKind::Watching)[0]; + assert_eq!(stored.id, video.id); + assert_eq!(stored.title, video.title); + assert_eq!(stored.channel, video.channel); + assert_eq!(stored.timestamp, video.timestamp); + assert_eq!(stored.publication_date, video.publication_date); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert!(!state.poll_progress().unwrap()); + append( + &report, + miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.is_watched(&video.id)); + assert!(!state.poll_progress().unwrap()); + for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { + let stored = &state.videos(kind)[0]; + assert_eq!(stored.id, video.id); + assert_eq!(stored.title, video.title); + assert_eq!(stored.channel, video.channel); + assert_eq!(stored.channel_id, None); + assert_eq!(stored.uploader_id, None); + assert_eq!(stored.timestamp, video.timestamp); + assert_eq!(stored.publication_date, video.publication_date); + } + for path in [ + directory.0.join("watch-later.toml"), + directory.0.join("saved-videos.toml"), + directory.0.join("currently-watching.toml"), + directory.0.join("watch-state.toml"), + report.with_file_name("video.toml"), + ] { + let contents = fs::read_to_string(path).unwrap(); + assert!(!contents.contains("private-token")); + assert!(!contents.contains("cdn.example")); + } + assert_eq!(fs::read(source_file).unwrap(), source_bytes); + assert!(report.exists()); + } + + #[test] + fn miruro_v2_requires_matching_episode_eligible_known_duration_and_threshold() { + for event in ["progress", "ended", "closed"] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = miruro_video(1); + let (_, report) = state + .miruro_session(&video, &miruro_source(&video)) + .unwrap(); + append( + &report, + miruro_record(&miruro_video(2), event, true, 100.0, Some(100.0)).as_bytes(), + ); + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&miruro_video(2).id)); + for (eligible, position, duration) in [ + (true, 0.0, Some(100.0)), + (true, 94.9, Some(100.0)), + (false, 100.0, Some(100.0)), + (true, 100.0, None), + ] { + append( + &report, + miruro_record(&video, event, eligible, position, duration).as_bytes(), + ); + state.poll_progress().unwrap(); + assert!(!state.is_watched(&video.id)); + } + assert!(state.contains(CollectionKind::Watching, &video.id)); + let completion = miruro_record(&video, event, true, 95.0, Some(100.0)); + append(&report, completion.as_bytes()); + append(&report, completion.as_bytes()); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.poll_progress().unwrap()); + } + } + + #[test] + fn miruro_v2_rejects_invalid_wire_schemas_numbers_and_namespaces() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = miruro_video(1); + let (_, report) = state + .miruro_session(&video, &miruro_source(&video)) + .unwrap(); + let valid = miruro_record(&video, "progress", true, 100.0, Some(100.0)); + for line in [ + valid.replace("\"version\":2", "\"version\":1"), + valid.replace("\"version\":2", "\"version\":3"), + valid.replace("\"provider\":\"miruro\"", "\"provider\":\"youtube\""), + valid.replace("\"episode\":1", "\"episode\":0"), + valid.replace("\"episode\":1", "\"episode\":2147483648"), + valid.replace("\"position_seconds\":100.0", "\"position_seconds\":-1"), + valid.replace("\"position_seconds\":100.0", "\"position_seconds\":1e999"), + valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":0"), + valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":-1"), + valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":1e999"), + valid.replace("\"event\":\"progress\"", "\"event\":\"other\""), + valid.replacen('{', "{\"media_url\":null,", 1), + record(&self::video().id, "ended", true, 100.0) + .replace("\"version\":1", "\"version\":2"), + ] { + append(&report, line.as_bytes()); + assert!(state.poll_progress().is_err(), "{line}"); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&video.id)); + assert!(!state.poll_progress().unwrap()); + } + } + + #[test] + fn miruro_partial_reports_and_bad_siblings_recover_after_restart() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = miruro_video(1); + let (source, report) = state + .miruro_session(&video, &miruro_source(&video)) + .unwrap(); + let completion = miruro_record(&video, "closed", true, 95.0, Some(100.0)); + append(&report, &completion.as_bytes()[..completion.len() - 1]); + assert!(!state.poll_progress().unwrap()); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(!state.poll_progress().unwrap()); + let other = miruro_video(2); + let (_, bad) = state + .miruro_session(&other, &miruro_source(&other)) + .unwrap(); + append(&bad, b"not json\n"); + append(&report, b"\n"); + assert!(state.poll_progress().is_err()); + assert!(state.is_watched(&video.id)); + assert!(!state.is_watched(&other.id)); + assert!(state.poll_progress().unwrap()); + assert!(!state.poll_progress().unwrap()); + drop(state); + assert!(source.exists()); + assert!(report.exists()); + let state = State::open(&directory.config()).unwrap(); + assert!(state.is_watched(&video.id)); + } + + #[test] + fn miruro_state_rejects_catalog_rows_bad_titles_and_youtube_metadata() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + for field in [ + "series", + "noncanonical", + "empty-title", + "control-title", + "channel", + "channel-id", + "uploader", + ] { + let mut video = miruro_video(1); + match field { + "series" => video.id = "miruro-series:opaque_Show-1".into(), + "noncanonical" => video.id = "miruro:opaque_Show-1:01".into(), + "empty-title" => video.title = " ".into(), + "control-title" => video.title.push('\n'), + "channel" => video.channel = Some("YouTube".into()), + "channel-id" => video.channel_id = self::video().channel_id, + "uploader" => video.uploader_id = Some("@jawed".into()), + _ => unreachable!(), + } + for kind in [ + CollectionKind::WatchLater, + CollectionKind::Saved, + CollectionKind::Watching, + ] { + assert!(state.save(kind, &video).is_err(), "{field}"); + } + assert!(state.progress_file(&video).is_err(), "{field}"); + } + for id in [ + "miruro-series:opaque_Show-1", + "miruro:opaque_Show-1:01", + "other:opaque_Show-1:1", + ] { + let mut watched = DocumentMut::new(); + watched["version"] = value(1); + let mut ids = Array::new(); + ids.push(id); + watched["watched"] = value(ids); + assert!(load_watched(&watched).is_err()); + } + } + + #[test] + fn miruro_source_sessions_validate_binding_size_and_maximum_identity_marker() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let mut video = miruro_video(i32::MAX as u32); + video.id = Identity::new("a".repeat(512), i32::MAX as u32) + .unwrap() + .key(); + let mut source = miruro_source(&video); + source.identity.episode -= 1; + assert!(state.miruro_session(&video, &source).is_err()); + assert!(state.streams.is_empty()); + source.identity = Identity::from_key(&video.id).unwrap(); + source.title = Some("x".repeat(MAX_SOURCE_BYTES)); + assert!(state.miruro_session(&video, &source).is_err()); + assert!(state.streams.is_empty()); + source.title = None; + let (_, report) = state.miruro_session(&video, &source).unwrap(); + append( + &report, + miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(), + ); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + drop(state); + let state = State::open(&directory.config()).unwrap(); + assert!(state.is_watched(&video.id)); + } + + #[test] + fn miruro_recovery_rejects_missing_snapshots_and_overlong_identity_markers() { + for missing_snapshot in [true, false] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = miruro_video(1); + let (_, report) = state + .miruro_session(&video, &miruro_source(&video)) + .unwrap(); + if missing_snapshot { + fs::remove_file(report.with_file_name("video.toml")).unwrap(); + } else { + append( + &report.with_file_name("video-id"), + &vec![b'x'; MAX_IDENTITY_BYTES as usize], + ); + } + append( + &report, + miruro_record(&video, "ended", true, 100.0, Some(100.0)).as_bytes(), + ); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.poll_progress().is_err()); + assert!(!state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + } + } + + #[test] + fn youtube_v1_reports_keep_canonical_urls_and_unknown_field_tolerance() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + let valid = record(&video.id, "ended", true, 95.0); + for url in [ + format!("https://youtu.be/{}", video.id), + format!("https://www.youtube.com/watch?v={}&t=1", video.id), + format!("http://www.youtube.com/watch?v={}", video.id), + ] { + append( + &report, + valid + .replace( + &format!("https://www.youtube.com/watch?v={}", video.id), + &url, + ) + .as_bytes(), + ); + assert!(state.poll_progress().is_err()); + assert!(!state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + } + append( + &report, + valid + .replacen('{', "{\"legacy_extension\":true,", 1) + .as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + } } diff --git a/src/youtube.rs b/src/youtube.rs index 22e2084..9e04f05 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -819,14 +819,67 @@ impl PlayerLaunch { )); } crate::video_state::register_identity(progress_file, id)?; - let mut player = isolated_command(player) + let mut command = isolated_command(player); + command .args([ "--new-instance", "--url", &format!("https://www.youtube.com/watch?v={id}"), ]) .arg("--progress-file") - .arg(progress_file) + .arg(progress_file); + Self::launch(command) + } + + /// Session files are owned and retained by video_state, not by this launcher. + pub fn start_source( + source_file: &Path, + player: &OsStr, + progress_file: &Path, + ) -> io::Result { + if !std::fs::symlink_metadata(source_file)?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Source document must be a regular file", + )); + } + let source_file = std::fs::canonicalize(source_file)?; + let parent = std::fs::canonicalize(progress_file.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Missing progress directory") + })?)?; + if source_file.parent() != Some(parent.as_path()) + || progress_file + .file_name() + .is_none_or(|name| name != "progress.jsonl") + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Source and progress must share their private session directory", + )); + } + let progress_file = parent.join("progress.jsonl"); + match std::fs::symlink_metadata(&progress_file) { + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "Progress destination must not exist", + )); + } + } + let mut command = isolated_command(player); + command + .arg("--new-instance") + .arg("--source-file") + .arg(source_file) + .arg("--progress-file") + .arg(progress_file); + Self::launch(command) + } + + fn launch(mut command: Command) -> io::Result { + let mut player = command .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped())