Compare commits
13 Commits
063177b7df
...
2279004e91
| Author | SHA1 | Date | |
|---|---|---|---|
| 2279004e91 | |||
| acdd6dc3e9 | |||
| 585907f876 | |||
| b12bba9125 | |||
| 01275e20b8 | |||
| b67a3d38c6 | |||
| f9e78c4549 | |||
| 2d30120072 | |||
| acfb3be1e7 | |||
| d8e26612e4 | |||
| 3fda432dbd | |||
| f21a1fbe63 | |||
| 17cef8e1bf |
Generated
+10
-2
@@ -79,6 +79,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
@@ -260,6 +266,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
@@ -1445,6 +1452,7 @@ name = "tubular"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"image",
|
||||
"libc",
|
||||
"ratatui",
|
||||
@@ -1508,7 +1516,7 @@ version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
@@ -1524,7 +1532,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
|
||||
@@ -6,6 +6,7 @@ description = "A terminal YouTube and Miruro browser that opens selected videos
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.7", features = ["derive"] }
|
||||
crossterm = { version = "0.29.0", default-features = false, features = ["osc52"] }
|
||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
|
||||
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
|
||||
|
||||
@@ -7,7 +7,7 @@ A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https:
|
||||
- A recent stable Rust toolchain, including Cargo, to build and install.
|
||||
- [`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 YouTube thumbnails and Miruro cover art.
|
||||
- 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 **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. Single videos use `--new-instance --url … --progress-file PATH`; multiple videos require `--new-instance --queue-file PATH` support. Miruro also requires native episode-URL resolution 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.
|
||||
|
||||
@@ -43,6 +43,8 @@ Startup shows a centered **YouTube / Miruro** selector. Use `j` / `k` or arrows
|
||||
|
||||
After selection, the browser starts in **normal mode**. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting.
|
||||
|
||||
In either provider's normal mode, press `p` (`app.provider`) to reopen the chooser with the current provider selected, then Enter to switch. Esc cancels and returns to browsing (unlike at startup); `q` or Ctrl-C quits. Both browser sessions stay in memory, preserving their queries, results, selection and navigation history when you switch back. Switching leaves running players unaffected and never reapplies the CLI query: it is used only for the initially opened provider. `--miruro` only skips the startup chooser; you can still switch providers with `p`.
|
||||
|
||||
Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. Entries use two lines: the title above dimmed metadata. YouTube shows the channel and publication date beneath each video title, without separate channel/date columns; tagged channels remain cyan. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `✓` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and title/episode metadata.
|
||||
YouTube's dimmed metadata row includes duration as `M:SS` or `H:MM:SS`, or `Unknown duration` when unavailable. Duration is retained in saved collections; older entries without duration remain readable.
|
||||
|
||||
@@ -55,19 +57,39 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \
|
||||
--progress-file '/private/session/progress.jsonl'
|
||||
```
|
||||
|
||||
Each selection opens an independent player instance without replacing existing playback. Tubular creates a private reporting session and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running.
|
||||
Plain Enter opens one independent player instance without replacing existing playback. Ctrl+Enter appends to a running player's queue instead. Tubular creates a private reporting session per video 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.
|
||||
|
||||
### Multiple video selection
|
||||
|
||||
In video and episode lists, **Space** checks/unchecks the hovered row; **Esc** discards checks. **Enter** plays checked rows in their current displayed order, regardless of the order they were checked. With no checks, Enter plays the hovered row. These controls also work in collection views and metadata previews; Miruro catalog titles and saved shows cannot be checked.
|
||||
|
||||
Selection and watched state share one marker slot: `*` for checked rows, otherwise `✓` for watched rows, otherwise blank. Checking a row only overrides its displayed marker, not its stored watch state. Category and tag selectors keep their checkboxes.
|
||||
|
||||
In normal browsing, Esc discards checks even when none are selected; `-` remains back navigation. Popups and text inputs keep their existing Esc close/cancel behavior. Ctrl-Space remains an alternative way to clear checks.
|
||||
|
||||
Checks belong to each cached view/screen and follow stable video identities through pagination and reordering. Refresh/replacement queries clear them; collection filtering/removal prunes checks for hidden/removed rows. Successful launcher creation clears checks, while immediate launch failures retain them. Saving, removing and marking watch status still affect the hovered video, not every checked row. Configure `video.select.toggle` and `video.select.clear`; terminals must transmit Ctrl-Space, or bind another chord.
|
||||
|
||||
Two or more checked videos launch `terebi --new-instance --queue-file '/private/session/queue.json'`. The private version-1 manifest contains ordered canonical episode/video page URLs and one independent `progress_file` per item. Terebi reuses its queue navigation and resolves each item when selected, not in advance; no expiring playback sources are stored. Each item's watching/watched state and resume identity remain independent, including after Tubular restarts. Single-video launches retain the command above. See the [queue contract](docs/terebi-url-playback-handoff.md#ordered-stream-queue-handoff).
|
||||
|
||||
**Ctrl+Return** (`video.enqueue`) sends the same checked rows, or the hovered row when none are checked, to a running Terebi queue without replacing its current playback. The terminal must transmit the modified Return, or you can rebind the action. Enqueue always uses `terebi --enqueue --queue-file '/private/session/queue.json'`, even for one video, preserving independent per-item progress. If Terebi is not running, it starts with that queue. Plain Return still opens a new instance. These shortcuts do nothing on Miruro catalog titles or saved shows and do not submit text inputs.
|
||||
|
||||
Tubular requests the Kitty keyboard protocol's escape-code disambiguation for its entire terminal session and restores the previous mode on exit. Supporting terminals can then distinguish Ctrl+Return from plain Return. A terminal or multiplexer that does not support/forward this protocol may still send identical bytes for both; Tubular cannot distinguish them. In that case, configure another key, for example `[keys.video]` with `enqueue = "alt+e"`.
|
||||
|
||||
### Miruro browser
|
||||
|
||||
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
|
||||
|
||||
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for cover art, a bold title, dimmed metadata and a synopsis excerpt, using the same info layout as YouTube. Press `d` again for the full scrollable synopsis and returned facts; another `d` returns to info, and Esc closes either mode. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first, above the series synopsis and beside the series cover art in info mode. Listed counts do not guarantee that a supported Sun source is available.
|
||||
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for cover art, a bold title, dimmed metadata and a synopsis excerpt, using the same info layout as YouTube. Press `d` again for the full scrollable synopsis and returned facts; another `d` returns to info, and Esc closes either mode. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first, above the series synopsis and beside the series cover art in info mode. Listed counts do not guarantee a playable source; Terebi resolves playback.
|
||||
|
||||
| Key | Miruro action |
|
||||
| --- | --- |
|
||||
| `i` / `/` | Edit the catalog query; Enter searches, Esc cancels |
|
||||
| `p` | Reopen the provider chooser in normal mode |
|
||||
| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode |
|
||||
| Enter | Open a series, or resolve a fresh direct **ssub / sun** source and play the episode |
|
||||
| Space / Esc | Check/uncheck the hovered episode / discard episode checks |
|
||||
| Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) |
|
||||
| Ctrl+Return | Append checked episodes, or the hovered episode, to a running Terebi queue (`video.enqueue`) |
|
||||
| `y` | Copy the hovered episode's page URL (also in previews and offline collections; `video.copy_url`) |
|
||||
| `-` / `+` | Back / forward through cached screens, preserving query and selection |
|
||||
| `w` / `v` | Save the selected episode to watch later / saved episodes |
|
||||
| `s` | Edit the selected show's category tags; Enter saves the show, including with no tags |
|
||||
@@ -85,11 +107,11 @@ These actions reuse the existing `[keys]` configuration; live hints and help omi
|
||||
|
||||
Space checks categories for combined browsing; Enter opens their deduplicated union even when the filter hides them. With no checks, Enter opens the hovered category or All saved shows.
|
||||
|
||||
Only episodes can have their watch status edited. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions.
|
||||
Only episodes can have their watch status edited. Reopening a saved episode passes its stable identity to Terebi for fresh resolution; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions.
|
||||
|
||||
Catalog results, series screens and saved-show lists use the returned `cover_url`; missing artwork leaves a text-only preview, and image failures do not hide metadata. Offline episode collections retain their stored text-only details. JPEG, PNG and WebP covers load, decode and resize off-thread, with native terminal graphics or proportional half-block rendering. The session image cache retains eight inactive images plus the current selection; a successful `r` refresh clears it.
|
||||
|
||||
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.
|
||||
Tubular owns catalog browsing, selection, collections and watched decisions. Terebi owns source resolution, stream/subtitle headers, subtitles, playback titles and optional timeline metadata. Miruro launches use `https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=RETURNED_EPISODE`; `episode` is a non-identifying placeholder slug, not a title. No source file or collection migration is required. Current Terebi displays the generic title `Miruro episode N`; title enrichment and intro/outro bands in the controls progress bar remain Terebi work. No automatic skipping or skip buttons are requested. See the [Terebi URL-playback handoff](docs/terebi-url-playback-handoff.md) for canonical-route handling and acceptance scenarios; this is not a claim of implemented timeline UI.
|
||||
|
||||
|
||||
## YouTube controls
|
||||
@@ -101,14 +123,18 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
|
||||
| Enter | Launch the selected video in Terebi |
|
||||
| Space / Esc | Check/uncheck the hovered video / discard checks |
|
||||
| Enter | Play checked videos in displayed order, or the hovered video when none are checked |
|
||||
| Ctrl+Return | Append checked videos, or the hovered video, to a running Terebi queue (`video.enqueue`) |
|
||||
| `p` | Reopen the provider chooser |
|
||||
| `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) |
|
||||
| `y` | Copy the hovered video's page URL (also in previews and offline collections; `video.copy_url`) |
|
||||
| `g` / Home, `G` / End | Select the first / last loaded video |
|
||||
| `i` | Edit the current category/combined feed search; other views use general YouTube Search |
|
||||
| `/` | Go directly to general YouTube Search |
|
||||
| `F` (Shift-F) | Go directly to the subscription Feed |
|
||||
| `d` | Open video info; press again for the full description, again to return to info |
|
||||
| Esc | Close the preview; otherwise return to the previous view |
|
||||
| Esc | Close a preview first; otherwise discard multi-selection without navigating |
|
||||
| `-` | Return to the previous view |
|
||||
| `+` | Go forward after navigating back |
|
||||
| `c` | Browse saved categories |
|
||||
@@ -136,6 +162,8 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
||||
|
||||
**Ctrl-C quits from any mode or popup.**
|
||||
|
||||
URL copying always uses the highlighted row, not the playback checks, and copies the same canonical page URL used for playback—not an expiring stream URL. It uses the terminal's OSC 52 clipboard support without external clipboard tools. Clipboard writes must be supported and enabled by your terminal and any multiplexer; Tubular can report sending the URL, but cannot confirm that the terminal accepted it. Text inputs and selectors keep `y` as ordinary input or ignore it.
|
||||
|
||||
The bottom bar shows essential actions for the current context. Press `?` in normal mode—including selectors and previews—for the full reference. Scroll help with `j` / `k`, arrows, Ctrl-J / Ctrl-K, or PageDown / PageUp; `?` or Esc closes it without changing the underlying view or dialog. In text fields, `?` remains ordinary input.
|
||||
The overview groups shortcut/action rows under bold section headers such as `VIEWS AND VIDEOS`, with an empty line beneath each header. Shortcuts and action descriptions remain normal weight; the rows reflect configured bindings rather than showing internal action paths.
|
||||
|
||||
@@ -284,11 +312,11 @@ Files are siblings of `config.toml`:
|
||||
- `currently-watching.toml`: versioned metadata for unfinished videos, added by playback or marked manually.
|
||||
- `watch-state.toml`: versioned global watched video IDs.
|
||||
- `miruro-library.toml`: versioned saved-show catalog metadata and overlapping category tags, independent of episode collections.
|
||||
- `playback-sessions/`: private per-launch progress streams, expected identities, metadata snapshots, durable admission/manual-edit markers, and Miruro source documents.
|
||||
- `playback-sessions/`: private per-video progress streams, expected identities, metadata snapshots, durable admission/manual-edit markers, and ordered `queue.json` manifests for multi-video launches.
|
||||
|
||||
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. 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.
|
||||
Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. Session directories are `0700`; snapshots, identity markers and Terebi-created reports are owner-only. New Miruro launches contain no resolved-source documents or CDN credentials. Existing sessions from earlier versions remain readable and are not automatically removed. 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
|
||||
|
||||
@@ -345,7 +373,7 @@ This prints TOML to stdout and exits without reading or writing config, starting
|
||||
video.info.overview = "I"
|
||||
video.info.description = "d"
|
||||
video.info.hide = ["escape", "backspace"]
|
||||
view.history.previous = ["-", "escape"]
|
||||
view.history.previous = "-"
|
||||
view.history.next = "+"
|
||||
video.play = ["enter", "ctrl+p"]
|
||||
```
|
||||
@@ -376,7 +404,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, source-document projection, stable episode/progress contracts, and verification limits.
|
||||
[Miruro API notes](docs/miruro-api.md) document the native catalog-only `tubular::miruro` wrapper, stable episode/progress contracts and historical source/CDN research. The [Terebi URL-playback handoff](docs/terebi-url-playback-handoff.md) defines the playback interface and remaining title/timeline work.
|
||||
|
||||
Unit tests live in `tests/unit/`, grouped by source module and loaded with `#[cfg(test)]` and `#[path]` so they retain access to private implementation details. Cargo runs them with the commands below; they are not separate integration-test crates.
|
||||
|
||||
@@ -386,10 +414,10 @@ cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Read-only native Miruro smoke (search → detail/episodes → fresh sources → HLS master/subtitle):
|
||||
Read-only native Miruro smoke (search → detail/episodes):
|
||||
|
||||
```sh
|
||||
cargo run --locked --example miruro_probe
|
||||
```
|
||||
|
||||
This uses the documented research title and direct Sun provider, stops on errors/protection pages, and does not download media segments or save tokenized URLs. The sample can become unavailable; passing it proves resource retrieval, not full player playback.
|
||||
This uses the documented research title and stops on errors/protection pages. The sample can become unavailable; passing it proves catalog retrieval, not Terebi playback. Earlier source/HLS/subtitle probe results remain historical evidence in the API notes.
|
||||
|
||||
+24
-38
@@ -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 includes a native API module, Miruro catalog/episode browsing, and direct Sun playback through Terebi's implemented source/progress contracts.
|
||||
Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**; ownership split updated **2026-10-22**. These are reverse-engineering notes, not an official API specification. Tubular now owns catalog/detail retrieval and stable identity; Terebi resolves episode URLs. Source/CDN investigations below are historical evidence, not the current launch path.
|
||||
|
||||
Evidence labels:
|
||||
|
||||
@@ -52,7 +52,7 @@ The minimum evidenced flow is:
|
||||
4. Fetch `/api/sources?id=<opaque id>&n=<selected number>` immediately before playback.
|
||||
5. Choose a returned track/provider/server with direct streams; preserve that server's headers and runtime configuration. Iframe-only servers are not native media URLs.
|
||||
|
||||
**Frontend:** [link helpers][link-helpers] generate `/info/<id>/<slug>` and `/watch/<id>/<slug>?ep=<number>`. The canonical title is the shorter trimmed English/Romaji title when both exist, otherwise the available English/Romaji, then native title/ID. Slugging applies Unicode NFD, removes combining accents, lowercases, removes non-ASCII-alphanumeric/non-whitespace/non-hyphen characters, trims, hyphenates whitespace, and collapses repeated hyphens. Display-language preference is separate. Missing/wrong-slug behavior is untested; preserve the deployed helper's convention rather than assuming an arbitrary slug works.
|
||||
**Frontend (2026-10-12):** [link helpers][link-helpers] generate `/info/<id>/<slug>` and `/watch/<id>/<slug>?ep=<number>`, with title-derived canonical slugs. **2026-10-22:** a placeholder watch slug returned a canonical-route redirect envelope; see the [URL handoff](terebi-url-playback-handoff.md#url-and-canonical-route-handling). Slugs are not episode identity. Tubular does not derive playback routes from display titles.
|
||||
|
||||
## Search and discovery contracts
|
||||
|
||||
@@ -192,7 +192,7 @@ Resolved watch `episodesData` has:
|
||||
|
||||
Watch `episodes[].id` is the number rendered as a string, **not a provider episode ID**. `aniskip` times are seconds; they differ in naming from detail `skip_times`. `/api/sources` does not carry those fields. **Frontend:** watch converts `op`/`mixed-op` to intro and `ed`/`mixed-ed` to outro, filters wrong episodes/invalid intervals and optional provider/vote/duration constraints, then recomputes skips using player-reported duration. No separate skip endpoint was found. Missing/invalid episode behavior and last-episode boundaries are untested.
|
||||
|
||||
## Source-resolution and playback contract
|
||||
## Historical source-resolution and playback research (2026-10-12)
|
||||
|
||||
### Plain source response
|
||||
|
||||
@@ -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 `<strmcx-embed>`, 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.
|
||||
|
||||
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).
|
||||
The former Tubular direct Sun integration supplied fresh URLs and headers to stream and VTT resources. That projection has been removed; Terebi now owns native URL resolution, HLS and subtitles. Other providers' transforms remain unverified by this research. See [the current integration](#tubular--terebi-integration).
|
||||
|
||||
## SvelteKit route data
|
||||
|
||||
@@ -387,32 +387,29 @@ console.log(response.status, response.headers.get('content-type'));
|
||||
console.log(await response.text());
|
||||
```
|
||||
|
||||
## Native Rust wrapper and verified probe
|
||||
## Native Rust catalog wrapper
|
||||
|
||||
The local probe succeeded using Rust 1.90.0, `ureq` 3.4.2, rustls 0.23.45 and the `ring` TLS provider. Its isolated scratch project was removed after the exercised path was extracted into [src/miruro.rs](../src/miruro.rs). The client uses ordinary TLS verification, no cookies, no browser impersonation, no clearance tokens and no retry/alternate transport.
|
||||
The historical native probe succeeded using Rust 1.90.0, `ureq` 3.4.2, rustls 0.23.45 and the `ring` TLS provider. The current `tubular::miruro` module retains catalog/detail retrieval, the bounded route decoder and stable identity, not source resolution. It uses ordinary TLS verification, no cookies, impersonation, clearance tokens or alternate transport.
|
||||
|
||||
Library interface, exposed as `tubular::miruro`:
|
||||
Current library interface:
|
||||
|
||||
| Call | Result / contract |
|
||||
| --- | --- |
|
||||
| `Client::default()` | Reusable blocking HTTP agent; 25-second global request timeout; no automatic redirects |
|
||||
| `client.search(query)` | `io::Result<Vec<serde_json::Value>>`; trimmed, nonempty autocomplete query; no cursor pagination |
|
||||
| `client.detail(id, slug)` | `io::Result<Detail>`; decoded `media` JSON and `episodes: Vec<Value>`; validates returned media ID |
|
||||
| `client.sources(id, episode)` | `io::Result<Value>`; fresh full response preserving headers/config/subtitles; validates positive episode and returned episodeNumber/tracks |
|
||||
| `client.source_document(id, episode, title)` | `io::Result<SourceDocument>`; 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:<opaque ID>:<episode>` |
|
||||
| `identity.watch_url()` | Validates identity and produces `https://barelystarted.miruro.tv/watch/<opaque ID>/episode?ep=<returned episode>` |
|
||||
|
||||
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.
|
||||
Raw catalog methods preserve JSON metadata without a second deployment-specific schema. IDs/slugs are validated path segments; HTTP/HTML/Content-Type failures remain errors, not empty results. Successful bodies are limited to 2 MiB. Source selection, resource headers and subtitles belong to Terebi.
|
||||
|
||||
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.
|
||||
|
||||
Run the retained live smoke:
|
||||
The retained `cargo run --locked --example miruro_probe` smoke now covers search → detail/episodes only, not sources or playback.
|
||||
|
||||
```sh
|
||||
cargo run --locked --example miruro_probe
|
||||
```
|
||||
### Historical wrapper/source probe evidence (before URL cutover)
|
||||
|
||||
Actual output from the extracted wrapper:
|
||||
The output and verification below describe the former source-capable wrapper, not the current example:
|
||||
|
||||
```text
|
||||
search: 8 records; selected Miruro ID OERe_a0YS4h-DlsBDOeXwX7HOO6316tw
|
||||
@@ -425,44 +422,33 @@ PASS: native search -> detail/episodes -> sources -> HLS master + subtitle; no m
|
||||
|
||||
The preceding scratch probe also reported HTTP 200 for search (`application/json`), detail (`text/sveltekit-data`, seven records), and sources (`application/json`). Detail decoding found two episodes and selected `episode_number:1`; source decoding found two tracks. The master and default subtitle were fetched using the actual returned `Origin: https://ultracloud.cc` and `Referer: https://ultracloud.cc/`. URL/header necessity and full playback were not tested.
|
||||
|
||||
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.
|
||||
The former source-capable example used the documented title and `ssub/sun`, limiting requests to HTTPS `*.keeply.top` without redirects. It fetched no nested playlist, media segment, key, sprite or download page and saved no tokens/bodies. Those resource requests are no longer in the catalog example.
|
||||
|
||||
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.
|
||||
|
||||
## Tubular → Terebi integration
|
||||
|
||||
`tubular` offers a centered YouTube/Miruro startup selector; an initial query follows the chosen provider. `tubular --miruro "apothecary"` bypasses it and starts the dedicated Miruro interface directly. `src/miruro_ui.rs` owns catalog → series → episodes navigation, synopsis/metadata presentation, cached screen history and flat episode collections; YouTube retains its separate Search/Feed/category interface. Catalog Enter opens actual returned episodes; episode Enter resolves fresh sources off-thread. Autocomplete is limited to eight titles, without invented pagination. `src/miruro_browser.rs` supplies native catalog/series/episode models and one worker for bounded network work; changing views/queries or episode selections invalidates stale source responses rather than launching obsolete selections. Canonical title/slug rules follow the [deployed helper][link-helpers], including Unicode NFD normalization.
|
||||
The startup selector and `--miruro` open the dedicated catalog → series → episodes interface. `miruro_ui` owns navigation, metadata presentation, cached history and collections; `miruro_browser` supplies catalog/episode models and catalog/detail worker requests. Autocomplete returns at most eight titles. Episode Enter passes a stable URL to Terebi; no Tubular source request or projection remains.
|
||||
|
||||
Both browsers share `src/ui.rs` for layout, search editing/cursor scrolling, selection styling and loading animation. Miruro uses the same full-width results/status chrome and centered metadata/help/removal popups as YouTube, while preserving its own catalog/episode rows and navigation. Press `d` for metadata; arrows/Ctrl-j/k scroll it, while plain j/k change the selected row.
|
||||
|
||||
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
|
||||
### URL launch and ownership
|
||||
|
||||
`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:<opaque catalog ID>, episode:<returned episode_number>}`; 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:
|
||||
Both providers use `State::progress_file(video)` and `PlayerLaunch::start(&video.id, player, progress_file)`. Miruro's `Identity::from_key(id)?.watch_url()?` produces:
|
||||
|
||||
```sh
|
||||
terebi --new-instance --source-file '/private/session/source.json' \
|
||||
terebi --new-instance \
|
||||
--url 'https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=RETURNED_EPISODE' \
|
||||
--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.
|
||||
The literal `episode` slug is non-identifying. Terebi's parser uses opaque ID plus episode; metadata acquisition must safely resolve canonical-route redirect envelopes. See the actionable [Terebi URL-playback handoff](terebi-url-playback-handoff.md).
|
||||
|
||||
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.
|
||||
Tubular registers the expected stable identity in a private reporting session before launch. Terebi exclusively creates the report (`0600`); session directories remain private (`0700`). CLI exit acknowledges launch acceptance, not media readiness. Retention, the 4,096-session cap, explicit inactive-session cleanup and restart recovery remain unchanged; close corresponding players before removing sessions. No new `source.json`, source-v2 contract, segment file, persistent `Video` fields or collection migration is required.
|
||||
|
||||
Terebi owns fresh sources, headers, subtitles, playback titles, resume and optional timeline metadata for both direct and Tubular URL launches. Current native URL/progress support is not title/timeline completion: the title is currently `Miruro episode N`; title enrichment and intro/outro progress-bar bands remain Terebi work. Optional metadata failure must not prevent playback. No caller-specific source fallback, automatic skipping or skip buttons are requested.
|
||||
|
||||
### Progress and collections
|
||||
|
||||
@@ -476,9 +462,9 @@ Tubular validates the version, namespace, media ID, episode, event and numeric f
|
||||
|
||||
Matching acknowledged positive positions admit currently-watching entries. Watched status retains `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`; null duration is ineligible. Seeking near the end can satisfy it. Launch/window closure alone cannot. Duplicate records, partial trailing lines and later reports recovered after Tubular restart retain the existing session-consumption behavior.
|
||||
|
||||
Watch later, saved videos, currently watching and watched state accept stable episode keys `miruro:<opaque ID>:<canonical episode>`. Catalog results use opaque media IDs in native `Media` rows, not fabricated video/episode keys; they are not saveable/playable episode identities. Saved episode playback resolves fresh sources; CDN addresses/tokens never become collection keys. Playback and persisted state remain shared, but each interface filters collections before display, selection, counts and removal. Miruro has no YouTube channel identity, so its flat episode lists exclude category/tag/channel actions and YouTube extraction/thumbnails. Planned/listed counts are metadata, not a claim of supported source availability; only actual returned episode numbers are offered for playback.
|
||||
Collections and watched state use `miruro:<opaque ID>:<canonical episode>`. Catalog rows are not playable episode identities. Saved and old snapshot playback needs only that key, not a canonical slug, title-derived route or stored source URL. Terebi resolves fresh resources; CDN tokens never become collection keys. Each interface filters shared collections by provider. Only actual returned episode numbers are selectable; planned counts do not guarantee source availability.
|
||||
|
||||
### Integration verification
|
||||
### Historical source-file integration verification (before URL cutover)
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Miruro video-segment feasibility
|
||||
|
||||
Investigated **2026-10-22**, scoped to **https://barelystarted.miruro.tv**. Investigation only: no production changes, media playback, playlist/segment/subtitle downloads, browser testing, or remote mutations.
|
||||
|
||||
**Conclusion (updated after the approved ownership split):** Miruro supplies optional episode-relative opening/ending intervals. Terebi must acquire and display them through its native episode-URL resolver; Tubular owns catalog/selection/collections/watched decisions only. Current Terebi does not display these intervals. The former source-file recommendation below is superseded by the [URL-playback handoff](terebi-url-playback-handoff.md). Availability is sparse; actual media-duration bounds are necessary but do not prove alignment across cuts.
|
||||
|
||||
Evidence labels: **Observed** = responses obtained today; **Frontend** = today's deployed first-party JavaScript, not a server guarantee; **Earlier** = the 2026-10-12 investigation in [miruro-api.md](miruro-api.md); **Recommendation** = proposed minimum behavior, not implemented.
|
||||
|
||||
## Live metadata
|
||||
|
||||
**Observed — historical native Tubular probe before URL cutover:** the then-existing `Client::detail`, `Client::sources`, and `SourceDocument::from_sources` path ran against the [detail route][detail] and [sources endpoint][sources]. The temporary example was removed afterward; `cargo run --locked --example miruro_segments_probe` describes that investigation, **not a permanent runnable command**. Source resolution/projection has since been removed from Tubular. Safe output projection:
|
||||
|
||||
```text
|
||||
detail: 3 episodes
|
||||
episode 1: absolute_number=49 duration_seconds=1500
|
||||
skip_times=[{kind:op,start_seconds:63.313,end_seconds:153.237},
|
||||
{kind:ed,start_seconds:1335.252,end_seconds:1425.384}]
|
||||
episode 2: absolute_number=50 duration_seconds=1500 skip_times=[]
|
||||
episode 3: absolute_number=51 duration_seconds=1500 skip_times=[]
|
||||
sources top-level keys: config episodeNumber providerOrder tracks
|
||||
projected source-document keys: headers identity subtitles title url version
|
||||
```
|
||||
|
||||
Those are episode **1** intervals, not absolute episode 49 or a provider-specific episode identifier. Two empty arrays show why metadata must remain optional; they do not establish that those episodes have no opening/ending.
|
||||
|
||||
**Observed — watch metadata:** JavaScript `fetch` of the [watch route][watch] returned **HTTP 200**, **`text/sveltekit-data`**, 33,483 text characters. The newline-delimited SvelteKit response contained an `episodesData` promise chunk; decoding its indexed table yielded:
|
||||
|
||||
```json
|
||||
{
|
||||
"episodes": [
|
||||
{"number": 1, "duration": 1500},
|
||||
{"number": 2, "duration": 1500},
|
||||
{"number": 3, "duration": 1500}
|
||||
],
|
||||
"currentEpisode": {"number": 1},
|
||||
"prevEpisode": null,
|
||||
"nextEpisode": {"number": 2},
|
||||
"aniskip": [
|
||||
{"episode": 1, "type": "op", "start": 63.313, "end": 153.237},
|
||||
{"episode": 1, "type": "ed", "start": 1335.252, "end": 1425.384}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This is an abbreviated projection: episode objects have additional descriptive fields. The two `aniskip` objects are complete and have **no `provider`, `votes`, or `episode_length`**. Both routes express these intervals in seconds, retaining fractional precision; `skip_times` uses `kind/start_seconds/end_seconds`, whereas watch uses `type/start/end` plus the episode number. The sampled values match across both routes. `/api/sources` contains no segment field; a source-only request cannot recover these sampled intervals.
|
||||
|
||||
**Earlier vs current:** [miruro-api.md](miruro-api.md#detail-episodes-and-watch-data) recorded two available episodes on 2026-10-12; today's probes found three. Its detail/watch shapes and episode-1 intervals are revalidated above. Its old hashed frontend links are not today's deployment. The current watch Content-Type is `text/sveltekit-data`, so do not assume every route-data response is `application/json`. Native `ureq` success and previous curl/Cloudflare failure are transport-specific observations, not permission or an availability guarantee.
|
||||
|
||||
## Deployed first-party normalization
|
||||
|
||||
**Frontend:** today's [homepage][home] imports [`app.Bj5KeT2s.js`][app], whose route dictionary maps `/watch/[id]/[slug]` to node 11, [`11.DKyvW9qB.js`][client]. In that watch bundle, function `wn` and sets `xn`/`Sn` implement this complete mapping:
|
||||
|
||||
| Input `type` | Emitted player segment |
|
||||
| --- | --- |
|
||||
| `op`, `mixed-op` | `{kind:"intro", start, end}` |
|
||||
| `ed`, `mixed-ed` | `{kind:"outro", start, end}` |
|
||||
| Any other value, including `recap` | No segment from this normalizer |
|
||||
|
||||
Only `op` and `ed` occurred in today's metadata. The mixed variants are **frontend-supported**, not live-observed API records. There is no evidenced exhaustive server enum, recap interval, preview interval, provider-specific interval, or mixed-kind decomposition. In particular, `mixed-op` is treated as one whole intro interval, not split into opening and recap portions.
|
||||
|
||||
The algorithm in `wn(records, episodeNumber, duration)` is:
|
||||
|
||||
1. Return `[]` when records are empty, the episode is null, or duration is absent/nonpositive.
|
||||
2. Reject a record when its optional `provider`, lowercased, contains `crunchyroll`; its `episode` is not strictly equal to the selected episode number; or `(votes ?? 0) < 0`.
|
||||
3. Require finite `start` and `end`, `start >= 0`, `end > start`, and `end <= duration` (an end exactly at duration is allowed).
|
||||
4. When `episode_length` is a positive number, require `abs(episode_length - duration) / duration <= 0.05`. Missing/non-number/nonpositive lengths bypass this comparison. This optional field was absent in the sample, so catalog duration 1500 does **not** trigger that 5% check by itself.
|
||||
5. Sort survivors by descending `(votes ?? 0)`. Take the first opening-kind record and the first ending-kind record. Emit intro first, then outro; at most two segments. It does not merge, scale, shift, trim, or resolve overlapping intro/outro intervals.
|
||||
|
||||
Useful anchors in the minified [bundle][client]: `Cn=.05`, `function wn`, `provider?.toLowerCase().includes(\`crunchyroll\`)`, `episode_length`, and `strmcx-duration-change`. The provider check concerns the **metadata record's provenance**, not equality with the currently selected stream provider; the algorithm has no selected-provider argument. Vote/provider/duration-length rules are consumer behavior, not evidence that Miruro currently returns those optional fields.
|
||||
|
||||
**Frontend timing/lifecycle:** the player component starts with empty segments and resets them, together with duration, during source resolution. A `strmcx-duration-change` event with positive numeric duration invokes `wn` using player-reported duration, the selected episode number, and watch `aniskip`. The normalized array is passed as `segments` to the strm.cx session, separately from the `options.autoskip` preference. Catalog `episode.duration` is not used as the normalization duration. Iframe playback takes a separate embedded-URL path rather than this direct-player session; the inspected code does not establish segment handoff to an arbitrary iframe provider.
|
||||
|
||||
## Limits and minimum recommendation
|
||||
|
||||
- **Observed:** metadata exists and is not tied to an expiring source URL. **Unknown:** coverage across the catalog, a stable/public metadata contract, how timings were generated, non-opening/ending availability, and alignment across providers/dubs/cuts. Merely being in bounds does not prove synchronization with a particular cut.
|
||||
- **Recommendation:** reuse the existing detail episode metadata; retain selected episode identity, optional raw kinds, and fractional second boundaries alongside it. Normalize only the four evidenced opening/ending kinds for initial intro/outro behavior. Do not relabel recap or unknown kinds as intro/outro or invent missing intervals.
|
||||
- **Approved seam (supersedes the earlier source-session recommendation):** Terebi's existing episode-URL resolver owns optional metadata acquisition and validation against actual media duration. Direct and Tubular launches must use the same path. No source-document version change, separate segment file, new endpoint or Tubular metadata persistence is required. Preserve optional provenance/reference-length fields if available; do not invent fields absent from the sample or claim bounds prove synchronization.
|
||||
- **Recommendation:** address every launch path, including saved/watch-later/currently-watching reopening, rather than enriching only a currently open catalog episode.
|
||||
|
||||
## Tubular → Terebi seam
|
||||
|
||||
The approved implementation contract and acceptance scenarios are consolidated in [Terebi URL-playback handoff](terebi-url-playback-handoff.md). Tubular launches the stable opaque ID and returned episode number through `PlayerLaunch::start`, using a non-identifying `episode` watch slug. Old/offline snapshots need no new fields or migration. Terebi owns canonical-route resolution, title enrichment and optional timeline metadata; title text is not identity.
|
||||
|
||||
The requested UI is **intro/outro bands in the controls progress bar only**, not automatic skipping or skip buttons. Validate episode binding, finite ordered nonnegative times and actual-duration bounds; clear metadata on media replacement. Empty, unavailable or invalid metadata must not prevent ordinary playback, and unknown kinds must not be relabeled.
|
||||
|
||||
### Historical source-file decoder evidence (superseded transport)
|
||||
|
||||
Before this cutover, Tubular retained `skip_times` in browsing metadata but omitted it from generic `Video` snapshots and source documents. Terebi's source-v1 decoder did not retain intervals. The following isolated decoder observation explains why adding JSON fields alone would not have implemented timeline display; it is not a recommendation to restore source files.
|
||||
|
||||
**Observed — actual decoder smoke:** compiled the existing `ResolvedSource.swift` with a temporary Foundation-only harness, then called `ResolvedSource.load(from:)` on synthetic local source documents. No player/network/GUI was started.
|
||||
|
||||
```text
|
||||
interval-array: accepted; segments absent from ResolvedSource
|
||||
wrong-type: accepted; segments absent from ResolvedSource
|
||||
version-2: rejected
|
||||
```
|
||||
|
||||
Thus merely adding `segments` to a version-1 JSON file would silently discard it, even with a malformed value. Changing the version alone also does not work. This is a source-document version, independent of the existing version-2 progress records.
|
||||
|
||||
The earlier recommendation to introduce a new source-document version and carry intervals through Tubular is **superseded**. No source-v2 or segment file is required. Terebi's native URL resolver already owns `/api/sources`; it must additionally acquire title and optional episode metadata via canonical route data for all callers. See the handoff for lifecycle and acceptance requirements. Existing source-v1 decoder observations above remain historical evidence only.
|
||||
|
||||
|
||||
## Fetch record
|
||||
|
||||
All requests were read-only. Static source was inspected, not executed as a webpage. No expiring source URL/token was printed or saved.
|
||||
|
||||
```javascript
|
||||
// Research eval JavaScript transport; each response's status/type was inspected.
|
||||
await fetch("https://barelystarted.miruro.tv/");
|
||||
await fetch("https://barelystarted.miruro.tv/_app/immutable/entry/app.Bj5KeT2s.js");
|
||||
await fetch("https://barelystarted.miruro.tv/_app/immutable/nodes/11.DKyvW9qB.js");
|
||||
await fetch("https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json?ep=1");
|
||||
```
|
||||
|
||||
The homepage/app were first fetched with the static `read` tool (`:raw` for imports); the watch bundle was read and then fetched with eval: **200 `text/javascript`**, 84,045 characters. Watch route: **200 `text/sveltekit-data`**, 33,483 characters; only decoded episode/segment metadata was printed, never `sourcesData`. The native detail/source probe is documented above. The decoder smoke used `swiftc -parse-as-library ../terebi/Sources/App/ResolvedSource.swift <temporary harness> -o <temporary binary>`, followed by running that binary. Both temporary source harnesses and the decoder's generated fixture/binary were removed. No test suites, linters, formatters, full application builds or UI smoke tests were run; only the native metadata probe and isolated decoder harness were compiled and executed.
|
||||
|
||||
[home]: https://barelystarted.miruro.tv/
|
||||
[app]: https://barelystarted.miruro.tv/_app/immutable/entry/app.Bj5KeT2s.js
|
||||
[client]: https://barelystarted.miruro.tv/_app/immutable/nodes/11.DKyvW9qB.js
|
||||
[detail]: https://barelystarted.miruro.tv/info/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json
|
||||
[watch]: https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json?ep=1
|
||||
[sources]: https://barelystarted.miruro.tv/api/sources?id=OERe_a0YS4h-DlsBDOeXwX7HOO6316tw&n=1
|
||||
@@ -0,0 +1,135 @@
|
||||
# Terebi episode-URL playback handoff
|
||||
|
||||
Approved ownership split, 2026-10-22. This document hands work to the Terebi team; it does not claim Terebi implementation or visible-player proof. Tubular cutover checks are recorded below. Primary metadata evidence, sampled intervals, frontend normalization and investigation limits are in [Miruro segment research](miruro-segments.md). Earlier source/CDN evidence is retained in [Miruro API research](miruro-api.md).
|
||||
|
||||
## Ownership and current support
|
||||
|
||||
- **Tubular:** catalog/detail browsing, selecting actual returned episodes, collections, reporting-session identity and watched decisions.
|
||||
- **Terebi:** episode-URL parsing, fresh source resolution, headers, subtitles, playback title/metadata, resume state and intro/outro visualization.
|
||||
|
||||
Terebi already accepts native Miruro episode URLs, resolves sources and supports stable version-2 episode progress. Its current title is `Miruro episode N`; enriching that title and displaying intro/outro intervals remain work for Terebi. A successful URL launch is not proof of either feature. Direct Terebi URLs and Tubular launches must use the same resolver and metadata lifecycle; do not add a caller-specific source-file fallback.
|
||||
|
||||
## URL and canonical-route handling
|
||||
|
||||
Tubular uses `PlayerLaunch::start(&videos, player, &mut state, enqueue)` for both providers; the launcher allocates and binds private reporting sessions through `State`. Plain Enter passes `false`, retaining a separate player; `video.enqueue` passes `true`. For Miruro, `Identity::from_key(id)?.watch_url()?` validates the stable identity and constructs:
|
||||
|
||||
```sh
|
||||
terebi --new-instance \
|
||||
--url 'https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=RETURNED_EPISODE' \
|
||||
--progress-file '/private/session/progress.jsonl'
|
||||
```
|
||||
|
||||
`episode` is a deliberate non-identifying placeholder slug. The existing Terebi `StreamURL.parse` accepts the slug and discards it, retaining opaque ID and episode. Never parse Tubular's combined display title to obtain a slug, identity or episode number. Use the returned episode number, not `absolute_number`, external AniList/MAL ID, array index or source-provider identifier.
|
||||
|
||||
**Observed read-only route probe, 2026-10-22:** `https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode/__data.json?ep=1` returned HTTP 200 `application/json` with a redirect envelope to `/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1`. This is route-data canonicalization, not necessarily an HTTP redirect. Terebi metadata acquisition must recognize the envelope, safely validate the destination against the expected Miruro origin, opaque ID and selected episode, and request the canonical route's `__data.json` while preserving `ep`. Bound redirect handling and reject loops or identity/origin changes; do not mistake HTTP 200 for decoded metadata success. Canonical watch data can be newline-delimited `text/sveltekit-data`, not just one JSON object. Decode response-local value tables and promised chunks as documented in the evidence.
|
||||
|
||||
A failed title/segment lookup or unsafe/unusable metadata redirect must not prevent otherwise resolvable ordinary playback. Retain a usable generic title when enrichment is unavailable; never change media identity to make metadata fit.
|
||||
|
||||
## Stable identity, sessions and progress remain unchanged
|
||||
|
||||
The collection key remains `miruro:<opaque ID>:<canonical episode>`. Runtime/report identity remains `{provider:"miruro", media_id:<opaque ID>, episode:<returned episode>}`. Titles, slugs and expiring CDN URLs are not identity. Metadata title enrichment must not create a new resume key or split an episode's history.
|
||||
|
||||
Tubular creates a private reporting session and registers its expected identity before launching. Terebi exclusively creates and appends the private progress report; Tubular consumes it independently of launcher lifetime. Existing retained sessions and collections, including old snapshots and reports recovered after restart, must remain readable. Do not migrate collections or add persistent `Video` fields to transport metadata. Existing session retention, explicit inactive-session cleanup and the 4,096-session cap remain in force; launcher exit, a final event or Tubular shutdown does not imply the independent player has stopped.
|
||||
|
||||
Progress-v2 is unchanged, for example:
|
||||
|
||||
```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 checks the complete identity against the launch session. Acknowledged positive positions admit currently-watching entries. Watched completion remains `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`; null duration is ineligible. Terebi owns resume positions. Existing partial-line/restart handling stays intact. YouTube canonical URLs, progress-v1 compatibility and watched policy are unchanged.
|
||||
|
||||
## Terebi metadata and timeline work
|
||||
|
||||
Acquire title and optional episode-bound intervals in the native URL resolver, not from `/api/sources` alone: the sampled sources response has no intervals. Watch `episodesData.aniskip` and detail `skip_times` contain equivalent sampled timings with different field names; use the existing route metadata surface rather than inventing another lookup service. Preserve episode binding and fractional seconds. Obtain a descriptive series/episode title from returned metadata, not a guessed title derived from the placeholder slug.
|
||||
|
||||
Requested visualization is **labeled opening/ending bands in the controls progress bar**, leaving the playhead and played/buffered indicators readable; do not distinguish the bands by color alone. No auto-skip, skip buttons or new seek behavior is requested. Normalize only evidenced kinds: `op`/`mixed-op` → intro, `ed`/`mixed-ed` → outro. Do not relabel `recap` or unknown kinds, invent absent intervals, or decompose mixed kinds. Raw unknown kinds may remain metadata but do not become intro/outro bands.
|
||||
|
||||
Validate finite start/end, `start >= 0`, `end > start`, selected episode match and `end <= actual media duration` (equality allowed). Wait for positive player-reported duration, not catalog duration; revalidate on duration changes. Invalid/out-of-bounds intervals should not be scaled, shifted or trimmed into plausible bands. Do not invent absent provenance, votes or reference-length fields; the frontend's optional checks and coverage/alignment limits are recorded in the primary evidence. In-bounds timings alone do not establish synchronization across different providers/cuts.
|
||||
|
||||
Start each media lifecycle with no intervals. Clear title/interval metadata on media replacement, bind asynchronous results to the current stable identity and reject stale responses, including results arriving after a different episode starts. Missing, empty, malformed or unavailable optional metadata leaves ordinary playback usable and the progress bar without those bands. No source-v2 contract, `--title` option, segment file, caller distinction or Tubular source projection is needed.
|
||||
|
||||
## Acceptance scenarios for the Terebi implementation
|
||||
|
||||
1. **Series selection:** launch a returned episode from Tubular's series screen using the placeholder URL; source resolution, headers/subtitles and canonical-route metadata use the native resolver. Compare a direct Terebi launch of the same URL: same descriptive title, identity, resume position and matching progress-v2 records.
|
||||
2. **Offline collections:** launch the same identity from saved, watch-later and currently-watching collections without first opening its series. Include an old snapshot with no slug, new metadata fields or intervals. All paths resolve fresh sources and acquire the same title/optional intervals without migration; offline collection browsing itself does not require network availability. Playback still requires resolvable resources.
|
||||
3. **Identity continuity:** canonical slug/title changes do not fork resume/progress state; wrong-episode/provider/media records cannot admit or complete another episode. Existing retained reports remain consumable after Tubular restarts; the unchanged 95% rule applies.
|
||||
4. **Optional metadata:** absent/empty metadata, malformed intervals, unknown kinds and failed metadata requests leave normal playback usable. Unknown kinds are not intro/outro. A valid sampled opening/ending appears as bands only, with no automatic seeking or added skip buttons.
|
||||
5. **Bounds and replacement:** with controlled metadata and player duration, reject negative/nonfinite/reversed/out-of-bounds or wrong-episode intervals; allow an end exactly at duration. Revalidate on actual-duration changes. Replace media while metadata is pending and confirm no old title/bands leak into the replacement; absent metadata clears earlier bands.
|
||||
6. **Canonicalization:** exercise the placeholder's 200 redirect envelope and canonical streamed route response. Unsafe origin/identity changes, loops or unavailable metadata fail only optional enrichment, not an independently resolvable source.
|
||||
7. **YouTube regression:** canonical YouTube URL launch, independent player lifetime, existing title/resume handling, progress-v1 ingestion and watched decisions remain unchanged.
|
||||
|
||||
These are acceptance requirements, not reported passes. Terebi implementation and visible progress-bar/title confirmation remain the receiving team's responsibility; no Terebi UI or browser verification was performed.
|
||||
|
||||
## Tubular cutover verification
|
||||
|
||||
Exercised locally on 2026-10-22:
|
||||
|
||||
- `cargo fmt --all --check`, `cargo test --locked` (**220 passed**), `cargo build --locked`, and the built executable's `--help` passed.
|
||||
- `cargo run --locked --example miruro_probe` returned eight catalog records, three episodes for the documented title, and an episode-1 URL. The example makes no source/CDN requests.
|
||||
- The actual terminal application launched a live catalog episode and a legacy snapshot from saved, watch-later and currently-watching views through `--new-instance --url ... --progress-file ...`. A capture/report launcher fixture confirmed matching stable identities, no `source.json`, private session/report modes, positive v2 watching admission, rejection of a wrong-episode completion, and matching completion recovered after restart. YouTube's actual terminal launch retained its canonical URL and v1 progress admission.
|
||||
- `cargo clippy --locked --all-targets -- -D warnings` is blocked by findings in untouched files: `src/youtube/catalog.rs:184` (`collapsible_if`) and `src/titles.rs:492` (`reversed_empty_ranges`).
|
||||
|
||||
The launcher fixture did not run Terebi. Native Terebi playback, title enrichment and visible timeline bands are not verified by these Tubular checks. Temporary smoke fixtures were removed; the user-facing catalog probe remains.
|
||||
|
||||
## Ordered stream queue handoff
|
||||
|
||||
Multiple-video selection extends the URL handoff without transporting resolved sources or changing progress records. Tubular sends checked rows in displayed order; it does not combine providers' browsing sessions into one selection. Terebi accepts either provider in a queue and preserves the supplied order instead of applying local-file filename sorting.
|
||||
|
||||
```sh
|
||||
terebi --new-instance --queue-file '/private/session/queue.json'
|
||||
```
|
||||
|
||||
The version-1 manifest is:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"items": [
|
||||
{
|
||||
"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"progress_file": "/private/playback-sessions/session-A/progress.jsonl"
|
||||
},
|
||||
{
|
||||
"url": "https://barelystarted.miruro.tv/watch/opaque_Show-1/episode?ep=3",
|
||||
"progress_file": "/private/playback-sessions/session-B/progress.jsonl"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The manifest contains 1–4,096 entries. Plain Enter uses it for batches of two or more; a single item retains `--new-instance --url` and `--progress-file`. Enqueue uses a manifest even for one item. `--queue-file` requires either `--new-instance` or `--enqueue`, an absolute manifest path, and no other media argument or global `--progress-file`; those two launch modes cannot be combined. Terebi validates the entire manifest before delivery and again in the receiving application: supported page URLs, distinct stable identities, distinct absolute new report destinations, and plain owner-only report directories. POSIX-canonical report paths deduplicate macOS `/var` and `/private/var` aliases. Invalid batches fail rather than silently playing only their first entry.
|
||||
|
||||
Tubular validates all identities, snapshots and available session capacity before allocating a batch. It creates one existing-format snapshot/reporting session and identity marker per item; it never creates Terebi's report files. The manifest is `0600` inside the first retained `0700` session directory and contains no source URLs or transport credentials. Retention and the shared 4,096-session cap are unchanged; inactive queue entries count toward the cap too.
|
||||
|
||||
Terebi resolves only the selected queue item, through its ordinary YouTube/Miruro resolver and metadata lifecycle. Next/previous, sidebar selection, reordering/removal and natural-end advancement reuse the existing queue. Switching entries cancels stale resolution/metadata work without discarding the queue; failed playback does not mark an item watched. Local-file queues retain natural filename ordering. A submitted batch opens one independent player, not one window per checked item.
|
||||
|
||||
Each selected entry reports only to its own registered destination. Switching drains the previous writer; returning to an entry appends only to the original owned regular-file inode, never truncating or adopting an unrelated existing file. Resume keys and version-1 YouTube/version-2 Miruro identities remain stable. Tubular's watching admission, 95% completion rule, manual-status precedence and restart recovery consume these independent reports unchanged.
|
||||
|
||||
### Queue verification status
|
||||
|
||||
- Tubular: `cargo fmt --all --check`, `cargo test --locked` (**230 passed**), `cargo build --locked` and the built CLI's `--help` passed.
|
||||
- Both actual terminal interfaces were exercised with offline saved rows and a capture launcher: checking in reverse order produced a private two-item manifest in displayed order; successful submission cleared checks and the next Enter used single-video URL playback. This fixture did not run Terebi.
|
||||
- Terebi: `make check-cli check-queue check-reporting check-resolved all` passed. Headless native playback exercised mixed YouTube/Miruro queues, stale-result cancellation, manual navigation, natural-end advancement, fresh resolution/resume on revisits and isolated per-item reports.
|
||||
- The packaged GUI smoke did not complete: native state snapshots stalled. Temporary tracing and probe files were removed. Further UI testing was stopped at the user's request and remains user-owned; no complete GUI smoke or visual-layout pass is claimed. The TODO remains open for that confirmation.
|
||||
|
||||
## Append to a running player
|
||||
|
||||
```sh
|
||||
terebi --enqueue --queue-file '/private/session/queue.json'
|
||||
```
|
||||
|
||||
`video.enqueue` defaults to Ctrl+Return. It shares plain Enter's checked-display-order selection and hovered-row fallback in video/episode lists, previews and offline collections. Miruro catalog/show rows and text inputs do not enqueue. Plain Enter remains a new-instance launch.
|
||||
|
||||
The CLI delivers a manifest reference through an explicit-app native URL-open event, so later submissions reach an already running player rather than relying on startup arguments. The receiver appends in supplied order without restarting current playback, changing paused/playing intent, cancelling current resolution or resolving appended items early. An empty or newly launched player starts the first queued item. OS-level delivery to a running packaged app still needs user confirmation; headless receiver checks are not LaunchServices proof.
|
||||
|
||||
The complete request is rejected on an identity already in the current queue, a retained report identity, a retained report path, or the original single-video report identity. Removing a row does not release its registration. Registrations are atomic; failed batches do not reserve their valid prefix. The original single-video report remains independently navigable, and unregistered identities cannot leak into it once queue reporting is active. Errors after OS handoff appear in Terebi; Tubular's checks retain the existing clear-on-successful-launcher-spawn policy.
|
||||
|
||||
### Enqueue verification
|
||||
|
||||
- Tubular: `cargo fmt --all`, `cargo test --locked` (**233 passed**), `cargo build --locked` and built `--help`; Rust launcher diagnostics were clean.
|
||||
- Actual YouTube and Miruro terminal interfaces with injected modified-key sequences: reverse-checked rows produced display-ordered enqueue manifests, cleared checks fell back to the hovered row, Ctrl/Super+Return and an F12 override worked, and plain Return still used `--new-instance --url`. Six handoffs used private `0600` manifests in `0700` session directories with distinct report destinations. The capture launcher did not run Terebi, and injecting already-encoded modified keys did not verify terminal protocol negotiation.
|
||||
- Terebi: `check-cli`, `check-queue`, `check-reporting` and `make all` passed. A regression check first failed on rebinding the original single-video identity and passed after binding it independently of appended reports.
|
||||
- Headless native enqueue smoke with local video-only HLS passed: repeated delivery while paused/playing, unchanged current selection/revision, lazy mixed-provider append, natural-end advance, navigation/revisits, atomic rejection/rollback, isolated original/appended reports and empty-player startup.
|
||||
- Full `make check-resolved` stopped in the pre-existing mixed-queue natural-end scenario at `Could not start Core Audio (560227702)` before reaching enqueue. No full A/V-suite pass is claimed. No packaged-GUI/LaunchServices smoke or visual-layout confirmation was performed; the app was built, not installed. Temporary terminal/server fixtures were removed.
|
||||
- Modified-Return correction: a protocol-aware PTY driver reproduced Ctrl+Return opening a new instance when startup did not request disambiguation. After adding a session-scoped Kitty keyboard protocol push/pop, the same actual TUI driver observed Ctrl+Return using `--enqueue`, plain Return using `--new-instance`, and keyboard-mode restoration on exit. `cargo test --locked` remained **233 passed** and the build passed. This emulates a supporting terminal; it does not prove support in the user's terminal/multiplexer. Legacy identical Return bytes require a different binding.
|
||||
@@ -0,0 +1,121 @@
|
||||
# WCO feasibility investigation
|
||||
|
||||
Observed **2026-10-20 UTC**. Scope: adding Western-cartoon browsing and native playback to Tubular, using ordinary HTTPS without browser automation, challenge/login/premium bypass, fabricated ad-verification results, or weakened TLS. No production implementation was added.
|
||||
|
||||
## Decision
|
||||
|
||||
**Browsing is feasible for the sampled catalog. Native video extraction and playback are not established.** The current declared free-player route reaches a JavaScript ad-verification step; investigation stopped before submitting that verification or requesting the post-gate player. Separately, the current Tubular/Terebi source and progress contracts reject an honest WCO identity.
|
||||
|
||||
Do not build another full browser UI or weaken Terebi's network policy on this evidence alone. The missing prerequisite is a legitimately obtained final media URL, its required headers, and a successful native decode/seek probe. Nothing below demonstrates that WCO cannot ever support a native client.
|
||||
|
||||
Evidence labels:
|
||||
|
||||
- **Observed:** live requests or an executed check in this investigation.
|
||||
- **Source:** current site markup, JavaScript, or implementation inspection.
|
||||
- **[INFERENCE]:** architectural implication, not verified WCO playback.
|
||||
|
||||
## Live native HTTP evidence
|
||||
|
||||
A temporary Rust example used the project's existing `ureq 3.4.2` dependency with its `rustls` feature: default agent, TLS verification enabled, 25-second global timeout, `http_status_as_error(false)`, and zero automatic redirects. Responses were bounded to 512 KiB, or 4 KiB for the artwork signature check. No cookies, browser impersonation, proxy, script execution, or challenge solution was supplied. Player/initialization requests carried their referring page explicitly.
|
||||
|
||||
| Resource | Transport | HTTP | Content-Type | Body bytes read |
|
||||
| --- | --- | ---: | --- | ---: |
|
||||
| [Cartoon directory][directory] | ureq/rustls | 200 | text/html | 311285 |
|
||||
| [OTGW series][otgw] | ureq/rustls | 200 | text/html | 42840 |
|
||||
| [Adventure Time series][adventure] | ureq/rustls | 200 | text/html | 148050 |
|
||||
| [OTGW episode 1][episode] | ureq/rustls | 200 | text/html | 39154 |
|
||||
| Episode's declared iframe, `embed.wcostream.com/inc/embed/index.php` | ureq/rustls | 200 | text/html | 9177 |
|
||||
| [Declared player initialization][preinit] | ureq/rustls | 200 | application/javascript | 3871 |
|
||||
| [OTGW cover][cover] | ureq/rustls | 200 | image/jpeg | 4096 |
|
||||
| Same OTGW episode-1 URL | Python urllib | 403 | text/html | 5601 |
|
||||
| Same OTGW episode-1 URL | curl | 403 | text/html | 5580 |
|
||||
|
||||
**Observed:** the cover prefix has a JPEG signature. Full image decoding was not tested. Native successful page responses contained neither the Cloudflare challenge markers checked nor the explicit premium-only marker. Python's episode response contained Cloudflare's “Just a moment”; curl independently returned 403 HTML. Accessibility is transport-dependent; successful web-reader access alone would not have established local native access.
|
||||
|
||||
## Smallest catalog integration
|
||||
|
||||
**Observed/Source:** the directory and series metadata are server-rendered. No JavaScript execution is needed to read the sampled listings.
|
||||
|
||||
1. `GET /cartoon-list`; parse `#ddmcc_container` title/series links.
|
||||
2. Filter that Western-cartoon directory locally. The site's own directory search performs case-insensitive substring filtering, not another API request.
|
||||
3. Fetch the selected series page for description, genres, cover reference, season labels, and `#episodeList a.dark-episode-item` entries.
|
||||
4. Preserve the provider's actual playable-item labels and opaque page paths.
|
||||
|
||||
The global search form declares **POST `/search`**, with `catara=<query>` and `konuara=series` or `episodes`; the input declares a 40-character maximum. This is a markup contract, **not a successful live POST probe**. Local directory filtering avoids depending on that unverified endpoint for Western-cartoon title search. [Site form][otgw]; [reference search implementation][kodi-search].
|
||||
|
||||
Series identity should use the observed canonical provider path, not a slug generated from the display title or the numeric artwork filename. The directory contains historical paths such as `/anime/american-dad-season-1` for “American Dad!”. Long-term URL stability has not been established. [Directory][directory].
|
||||
|
||||
Artwork is available through `og:image` and `#sidebar_cat img.img5`; normalize scheme-relative references. Sidebar prose and genre links are available, but quality varies: Adventure Time's synopsis describes its pilot rather than the whole series. No episode runtime, publication date, or chapter timestamps were established.
|
||||
|
||||
### Identity and ordering traps
|
||||
|
||||
- OTGW has **eight page entries covering ten numbered segments**: 1–6 separately, 7–8 together, 9–10 together. These eight links were also extracted from the native response.
|
||||
- Adventure Time season 1 labels one video “Episode 1 Slumber Party Panic / Trouble in Lumpy Space”; season 10 has a combined “Episode 13–16 Come Along with Me”. Pilot, specials, Distant Lands and Side Quests coexist on the series page.
|
||||
- Map `data-season` through `#seasonFilter option[value]`; preserve the token and label. Adventure Time includes `s-1-0`, labeled “Special: Distant Lands”.
|
||||
- Read the direct title span, not all anchor text: quality/finale badges are separate metadata.
|
||||
- Preserve source order initially. It is mostly descending, but not consistently numerically sorted.
|
||||
- Use an opaque episode-page identity, not series ID plus an assumed single positive episode number. Do not synthesize chapters, segment boundaries, or separate watched states from slash-separated titles.
|
||||
|
||||
Sources: [OTGW][otgw], [Adventure Time][adventure]. The current Miruro identity cannot be reused unchanged: [`src/miruro.rs:28-77`](../src/miruro.rs).
|
||||
|
||||
## Actual page-to-player boundary
|
||||
|
||||
**Observed:** ordinary native requests reached this sequence:
|
||||
|
||||
1. The episode HTML contains a statically encoded iframe string. Its visible script applies Base64 decoding, retains decimal digits, subtracts a page-supplied constant, then constructs characters. This transform was decoded as data; no downloaded JavaScript was executed.
|
||||
2. The decoded iframe targets `embed.wcostream.com/inc/embed/index.php` with episode query parameters.
|
||||
3. That iframe returns an **“Announcement”** document, not an MP4/HLS URL. Its close button is initially disabled, with a 10-second countdown and a 12-second fallback timer.
|
||||
4. The document loads [`/inc/embed/pre-init.js?v2`][preinit]. That script generates a browser nonce, performs DOM ad-bait and script-loading checks, and sends an `/ad-verify` beacon containing the nonce, video ID and `clear`/`blocked` result.
|
||||
5. Closing the announcement normally navigates to `video-js.php`, using `AdGate.buildUrl()` to append the nonce. The page also has a fallback navigation if `AdGate` is unavailable; that fallback was not requested to skip verification.
|
||||
|
||||
**Boundary:** no `/ad-verify` request was submitted; no DOM results were fabricated, scripts executed, announcement flow skipped, alternative player endpoint substituted, premium workaround invoked, or post-gate player/media requested. Server-side enforcement of the nonce was **not tested**. A script containing a gate is not proof that every WCO stream is inaccessible to a native client.
|
||||
|
||||
The [Kodi resolver][kodi-resolver] is reference code, not a proven current contract. It rewrites `index.php` to `video-js-new.php`, parses internal source JSON, resolves media redirects, and contains separate premium-workaround paths. Those behaviors were not transplanted or exercised. Its recent maintenance does not establish that this sample is playable through Tubular's permitted flow.
|
||||
|
||||
**Unverified:** current source-JSON schema after the gate; final media type/codecs; required headers/cookies; redirect chain; URL expiration/IP binding; byte-range/seek support; nested HLS origins; subtitles; actual Terebi playback. No episode media was downloaded.
|
||||
|
||||
## Actual Tubular/Terebi compatibility
|
||||
|
||||
### Executed source-contract smoke
|
||||
|
||||
A temporary Foundation-only Swift executable compiled **the actual** `../terebi/Sources/App/ResolvedSource.swift` unchanged. It validated a synthetic, otherwise valid source document with provider `miruro`, then changed only the provider to `wco` and required rejection. The synthetic example URL was not fetched.
|
||||
|
||||
Observed output, exit 0:
|
||||
|
||||
```text
|
||||
Actual Terebi validator: Miruro control accepted; identical document with provider=wco rejected
|
||||
```
|
||||
|
||||
This proves source-contract rejection, not anything about live media decoding. No player UI was launched.
|
||||
|
||||
### Required integration changes if usable media is established
|
||||
|
||||
- **Provider selection:** Tubular's chooser is Boolean/two-way, not a third-provider interface: `src/main.rs:3674-3698,3762-3774`.
|
||||
- **Honest stable identity:** Tubular's `Identity` validation/key parsing and Terebi's source validator hardcode Miruro. WCO needs a provider-correct opaque playback-item identity that accommodates paired episodes and specials. Do not disguise WCO media as Miruro. `src/miruro.rs:28-77`; `../terebi/Sources/App/ResolvedSource.swift:84-102`.
|
||||
- **Native admission and safety:** native progress identity validation accepts only canonical YouTube URLs or `miruro:`. Resolved-source security classification also uses `starts_with("miruro:")`. Updating Swift admission alone is insufficient and would not apply the protected resolved-source path. `../terebi/Sources/Core/progress_reporter.cpp:62-90`; `../terebi/Sources/Core/player.cpp:565-567,1460-1466`.
|
||||
- **Progress/state:** native version-2 reporting emits a literal Miruro provider. Tubular sessions, snapshot validation, report admission and provider-specific collection filtering also need coordinated changes. Broadening the Miruro identity parser without updating its UI callers would mix WCO records into Miruro collections. `../terebi/Sources/Core/progress_reporter.cpp:163-186`; `src/video_state.rs:379-396,651-669`; `src/miruro_ui.rs:265-268,989-990,1938-1941`.
|
||||
- **Reuse, not another player:** the existing source launcher already passes private source/progress files to Terebi. Resume storage is identity-keyed. No new playback framework is indicated. `src/youtube.rs:937-981`; `../terebi/Sources/App/TerebiApp.swift:516-524,1525-1530`; `../terebi/Sources/App/PlaybackProgress.swift:24-35,54-57`.
|
||||
|
||||
**[INFERENCE] Direct MP4 should fit the general native media architecture.** Source-file validation is not HLS-only; native FFmpeg opens input without forcing a demuxer and discovers audio/video streams. The HLS-only rule belongs to Miruro's Sun projection, not the general source document. WCO must not be routed through that projection. `src/miruro.rs:101-178`; `../terebi/Sources/Core/player.cpp:543-547,565-580`.
|
||||
|
||||
The existing protected network path rejects **all media redirects**, even same-origin redirects, and requires HLS variants/segments/keys/init resources to share the initial media origin. Preserve that policy. A resolver would need to obtain an already-final acceptable URL through a separately reviewed extraction flow; no need for redirect/cross-origin support has yet been established. `../terebi/Sources/Core/player.cpp:111-126,532-537`.
|
||||
|
||||
The existing `resolved-playback-check` is not an unchanged subtitle-free WCO smoke: it requires a default subtitle and two cues. The full player suite also requires graphical/audio resources. Neither was run. See `../terebi/Checks/resolved-playback.swift:24-80` and `../terebi/README.md:178-190`.
|
||||
|
||||
## Recommendation and limits
|
||||
|
||||
- **Go:** a native WCO catalog is evidenced for the sampled directory, two shows, episode page and cover host.
|
||||
- **Not yet go:** do not implement end-to-end WCO playback on the strength of catalog access or old extractor code.
|
||||
- **Next prerequisite:** observe/document an authorized normal free-player route through the verification boundary, or obtain a supported native playback interface. Browser observation would need explicit permission and would not itself prove native extraction. Do not emulate successful ad-verification results or weaken security to create a passing demo.
|
||||
- **Then:** resolve one real item, inspect only bounded media/manifest data with explicit headers, and verify native decode, duration and seek behavior before implementing browsing/state changes. Test a combined entry as well as a single episode before claiming identity correctness.
|
||||
|
||||
Investigation changes are this report only; temporary HTTP/Swift probes and response files were removed. No application behavior, player source, dependency, remote state, or persistent stream token was changed. Findings are a point-in-time sample, not a service reliability or distribution-rights guarantee.
|
||||
|
||||
[directory]: https://www.wco.tv/cartoon-list
|
||||
[otgw]: https://www.wco.tv/anime/over-the-garden-wall
|
||||
[adventure]: https://www.wco.tv/anime/adventure-time
|
||||
[episode]: https://www.wco.tv/over-the-garden-wall-episode-1-the-old-grist-mill
|
||||
[preinit]: https://embed.wcostream.com/inc/embed/pre-init.js?v2
|
||||
[cover]: https://images.wcostream.com/catimg/161158.jpg
|
||||
[kodi-search]: https://github.com/christianhaitian/plugin.video.watchnixtoons2/blob/kodi19/lib/plugin.py#L1201-L1263
|
||||
[kodi-resolver]: https://github.com/christianhaitian/plugin.video.watchnixtoons2/blob/kodi19/lib/plugin.py#L1353-L1641
|
||||
+7
-129
@@ -1,9 +1,8 @@
|
||||
//! Live read-only smoke: cargo run --locked --example miruro_probe
|
||||
//! Uses the documented research title and direct Sun provider; no media segments.
|
||||
use std::{io, time::Duration};
|
||||
//! Exercises native catalog metadata and stable episode URLs; no playback requests.
|
||||
use std::io;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use tubular::miruro::Client;
|
||||
use tubular::miruro::{Client, Identity};
|
||||
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -28,132 +27,11 @@ fn main() -> io::Result<()> {
|
||||
.and_then(|number| u32::try_from(number).ok())
|
||||
.ok_or_else(|| invalid("No episode metadata available"))?;
|
||||
println!(
|
||||
"detail: {} episodes; selected episode {number}",
|
||||
"detail: {} returned episodes; selected episode {number}",
|
||||
detail.episodes.len()
|
||||
);
|
||||
let sources = client.sources(id, number)?;
|
||||
let tracks = sources["tracks"]
|
||||
.as_array()
|
||||
.ok_or_else(|| invalid("Missing tracks"))?;
|
||||
let provider = 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 provider not returned"))?;
|
||||
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("No direct Sun stream returned"))?;
|
||||
let headers = server["headers"]
|
||||
.as_object()
|
||||
.ok_or_else(|| invalid("Missing server headers"))?;
|
||||
println!(
|
||||
"sources: {} tracks; selected ssub/sun; {} server headers retained",
|
||||
tracks.len(),
|
||||
headers.len()
|
||||
);
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(25)))
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.new_agent();
|
||||
let master = resource(
|
||||
&agent,
|
||||
"hls_master",
|
||||
server["streams"][0]["url"]
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid("Missing stream URL"))?,
|
||||
headers,
|
||||
)?;
|
||||
if !master.starts_with("#EXTM3U") || !master.contains("#EXT-X-STREAM-INF:") {
|
||||
return Err(invalid("Expected an HLS master playlist"));
|
||||
}
|
||||
let subtitle = provider["subtitles"]
|
||||
.as_array()
|
||||
.and_then(|subtitles| {
|
||||
subtitles
|
||||
.iter()
|
||||
.find(|subtitle| subtitle["default"] == true)
|
||||
})
|
||||
.ok_or_else(|| invalid("No default subtitle returned"))?;
|
||||
let subtitle = resource(
|
||||
&agent,
|
||||
"subtitle",
|
||||
subtitle["file"]
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid("Missing subtitle URL"))?,
|
||||
headers,
|
||||
)?;
|
||||
if !subtitle.starts_with("WEBVTT") || !subtitle.contains(" --> ") {
|
||||
return Err(invalid("Expected timed VTT subtitles"));
|
||||
}
|
||||
println!(
|
||||
"PASS: native search -> detail/episodes -> sources -> HLS master + subtitle; no media segments or tokens saved"
|
||||
);
|
||||
let identity = Identity::new(id.into(), number)?;
|
||||
println!("watch URL: {}", identity.watch_url()?);
|
||||
println!("PASS: native search -> detail/episodes -> stable watch URL; no playback requests");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resource(
|
||||
agent: &ureq::Agent,
|
||||
label: &str,
|
||||
url: &str,
|
||||
headers: &Map<String, Value>,
|
||||
) -> io::Result<String> {
|
||||
let uri: ureq::http::Uri = url
|
||||
.parse()
|
||||
.map_err(|_| invalid("Invalid Sun resource URL"))?;
|
||||
if uri.scheme_str() != Some("https")
|
||||
|| !uri.host().is_some_and(|host| host.ends_with(".keeply.top"))
|
||||
|| uri
|
||||
.authority()
|
||||
.is_some_and(|authority| authority.as_str().contains('@'))
|
||||
|| uri.port_u16().is_some_and(|port| port != 443)
|
||||
{
|
||||
return Err(invalid(
|
||||
"Smoke only permits the verified Sun HTTPS CDN; no redirects",
|
||||
));
|
||||
}
|
||||
let mut request = agent.get(url);
|
||||
for (name, value) in headers {
|
||||
request = request.header(
|
||||
name.as_str(),
|
||||
value
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid("Invalid server header"))?,
|
||||
);
|
||||
}
|
||||
let mut response = request
|
||||
.call()
|
||||
.map_err(|_| io::Error::other("Sun resource HTTP transport failed"))?;
|
||||
let status = response.status().as_u16();
|
||||
let mime = response.body().mime_type().unwrap_or("missing");
|
||||
println!("{label}: HTTP {status}; Content-Type {mime}");
|
||||
let expected = if label == "subtitle" {
|
||||
"text/vtt"
|
||||
} else {
|
||||
"application/vnd.apple.mpegurl"
|
||||
};
|
||||
if status != 200 || mime != expected {
|
||||
return Err(invalid(
|
||||
"Resource failed or returned unexpected content; stopping without retry",
|
||||
));
|
||||
}
|
||||
response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(2 * 1024 * 1024)
|
||||
.read_to_string()
|
||||
.map_err(|_| invalid("Resource response unreadable, timed out, or exceeded 2 MiB"))
|
||||
}
|
||||
|
||||
+26
-4
@@ -104,6 +104,7 @@ macro_rules! actions {
|
||||
actions! {
|
||||
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
|
||||
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
|
||||
ProviderChoose, "app.provider", "Choose the browsing provider", &[Context::Normal, Context::CollectionNormal], ["p"];
|
||||
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
|
||||
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or save category name", TEXT, ["enter"];
|
||||
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
|
||||
@@ -113,17 +114,21 @@ actions! {
|
||||
SearchEdit, "search.edit", "Edit general or current-category search", VIEW, ["i"];
|
||||
ViewSearch, "view.search", "Go to general YouTube Search", VIEW, ["/"];
|
||||
ViewFeed, "view.feed", "Go to the combined subscription Feed", VIEW, ["F"];
|
||||
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-", "escape"];
|
||||
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-"];
|
||||
ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"];
|
||||
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
|
||||
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
|
||||
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
|
||||
VideoPlay, "video.play", "Play the selected video", VIEW, ["enter"];
|
||||
VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"];
|
||||
VideoEnqueue, "video.enqueue", "Append checked videos in displayed order, or the selected video, to Terebi's queue", VIEW, ["ctrl+enter"];
|
||||
VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"];
|
||||
VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"];
|
||||
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"];
|
||||
VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"];
|
||||
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
|
||||
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
|
||||
VideoToggle, "video.select.toggle", "Toggle the selected video's playback check", VIEW, ["space"];
|
||||
VideoClear, "video.select.clear", "Discard playback checks in the current view", VIEW, ["escape", "ctrl+space"];
|
||||
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
|
||||
InfoDescription, "video.info.description", "Show the video description", VIEW, ["d"];
|
||||
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description, Context::CollectionInfo, Context::CollectionDescription], ["escape"];
|
||||
@@ -479,11 +484,24 @@ impl Bindings {
|
||||
"VIEWS AND VIDEOS",
|
||||
&[
|
||||
(&[VideoNext, VideoPrevious], "Select next / previous video"),
|
||||
(&[VideoPlay], "Play the selected video in Terebi"),
|
||||
(
|
||||
&[VideoPlay],
|
||||
"Play checked videos in displayed order, or the selected video",
|
||||
),
|
||||
(
|
||||
&[VideoEnqueue],
|
||||
"Append checked videos, or the selected video, to Terebi's queue",
|
||||
),
|
||||
(
|
||||
&[VideoToggle],
|
||||
"Check / uncheck the selected video for playback",
|
||||
),
|
||||
(&[VideoClear], "Clear playback checks in the current view"),
|
||||
(
|
||||
&[VideoOpen],
|
||||
"Open the selected YouTube video in the default browser",
|
||||
),
|
||||
(&[VideoCopyUrl], "Copy the selected video's URL"),
|
||||
(
|
||||
&[WatchStatusWatching],
|
||||
"Mark selected as watching (clear watched)",
|
||||
@@ -518,6 +536,10 @@ impl Bindings {
|
||||
(&[VideoFirst], "First loaded video"),
|
||||
(&[VideoLast], "Last loaded video"),
|
||||
(&[ViewRefresh], "Reload config and refresh"),
|
||||
(
|
||||
&[ProviderChoose],
|
||||
"Choose the browsing provider (NORMAL only)",
|
||||
),
|
||||
(&[Help], "Open / close this reference"),
|
||||
(&[Quit, Interrupt], "Quit"),
|
||||
],
|
||||
@@ -767,7 +789,7 @@ fn overlap_winner(context: Context, first: Action, second: Action) -> Option<Act
|
||||
},
|
||||
);
|
||||
}
|
||||
if pair(InfoHide, ViewPrevious)
|
||||
if (pair(InfoHide, ViewPrevious) || pair(InfoHide, VideoClear))
|
||||
&& matches!(
|
||||
context,
|
||||
Context::Info
|
||||
|
||||
+212
-67
File diff suppressed because one or more lines are too long
+13
-249
@@ -1,13 +1,8 @@
|
||||
//! Blocking access to the deployed Miruro catalog and source API.
|
||||
//! Blocking access to the deployed Miruro catalog and stable episode identities.
|
||||
//!
|
||||
//! 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,
|
||||
};
|
||||
//! See `docs/miruro-api.md`. Terebi owns playback source resolution, headers,
|
||||
//! subtitles and playback metadata.
|
||||
use std::{io, mem::size_of, time::Duration};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
@@ -60,6 +55,15 @@ impl Identity {
|
||||
format!("miruro:{}:{}", self.media_id, self.episode)
|
||||
}
|
||||
|
||||
/// The slug is non-identifying; metadata readers must resolve the canonical route.
|
||||
pub fn watch_url(&self) -> io::Result<String> {
|
||||
self.validate()?;
|
||||
Ok(format!(
|
||||
"{BASE}/watch/{}/episode?ep={}",
|
||||
self.media_id, self.episode
|
||||
))
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> io::Result<Self> {
|
||||
let (media_id, episode) = key
|
||||
.strip_prefix("miruro:")
|
||||
@@ -77,210 +81,6 @@ impl Identity {
|
||||
}
|
||||
}
|
||||
|
||||
#[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<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SourceDocument {
|
||||
pub version: u32,
|
||||
pub url: String,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub identity: Identity,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
pub subtitles: Vec<Subtitle>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<String> {
|
||||
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<BTreeMap<String, String>> {
|
||||
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<String, String>) -> 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 {
|
||||
@@ -333,42 +133,6 @@ impl Client {
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
/// Fresh source data, preserving tracks, headers, subtitles and runtime config.
|
||||
/// `episode` is the returned episode_number, not absolute_number or array index.
|
||||
pub fn sources(&self, id: &str, episode: u32) -> io::Result<Value> {
|
||||
validate_segment(id)?;
|
||||
if episode == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Episode must be positive",
|
||||
));
|
||||
}
|
||||
let body = self.get(
|
||||
"/api/sources",
|
||||
&[("id", id), ("n", &episode.to_string())],
|
||||
&["application/json"],
|
||||
)?;
|
||||
let value: Value = serde_json::from_str(&body).map_err(invalid)?;
|
||||
if value.get("episodeNumber").and_then(Value::as_u64) != Some(u64::from(episode))
|
||||
|| !value.get("tracks").is_some_and(Value::is_array)
|
||||
{
|
||||
return Err(invalid(
|
||||
"Miruro sources did not match the requested episode",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn source_document(
|
||||
&self,
|
||||
id: &str,
|
||||
episode: u32,
|
||||
title: &str,
|
||||
) -> io::Result<SourceDocument> {
|
||||
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<String> {
|
||||
let mut response = self
|
||||
.agent
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
sync::mpsc::{self, Receiver, SyncSender},
|
||||
thread,
|
||||
};
|
||||
use tubular::miruro::{Client, Detail, Identity, SourceDocument};
|
||||
use tubular::miruro::{Client, Detail, Identity};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -53,12 +53,10 @@ pub struct Series {
|
||||
pub enum Request {
|
||||
Search(String),
|
||||
Episodes { media_id: String, slug: String },
|
||||
Source(Video),
|
||||
}
|
||||
pub enum Response {
|
||||
Catalog(Vec<Media>),
|
||||
Series(Series),
|
||||
Source(Video, Box<SourceDocument>),
|
||||
}
|
||||
|
||||
// One running request, no queued network requests. The UI retains only its newest intent.
|
||||
@@ -84,11 +82,6 @@ impl Worker {
|
||||
.detail(&media_id, &slug)
|
||||
.and_then(|detail| series(&media_id, detail))
|
||||
.map(Response::Series),
|
||||
Request::Source(video) => Identity::from_key(&video.id)
|
||||
.and_then(|id| {
|
||||
client.source_document(&id.media_id, id.episode, &video.title)
|
||||
})
|
||||
.map(|source| Response::Source(video, Box::new(source))),
|
||||
};
|
||||
if results.send((generation, result)).is_err() {
|
||||
break;
|
||||
|
||||
+192
-93
@@ -93,6 +93,7 @@ struct DisplayRow {
|
||||
struct Screen {
|
||||
content: Content,
|
||||
selection: ListState,
|
||||
checked: BTreeSet<String>,
|
||||
description_scroll: u16,
|
||||
error: Option<String>,
|
||||
title: String,
|
||||
@@ -105,6 +106,7 @@ impl Screen {
|
||||
let mut screen = Self {
|
||||
content,
|
||||
selection: ListState::default(),
|
||||
checked: BTreeSet::new(),
|
||||
description_scroll: 0,
|
||||
error: None,
|
||||
title: String::new(),
|
||||
@@ -153,6 +155,42 @@ impl Screen {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn playable(&self) -> bool {
|
||||
matches!(
|
||||
self.content,
|
||||
Content::Series { .. } | Content::Collection { .. }
|
||||
)
|
||||
}
|
||||
fn toggle_checked(&mut self) {
|
||||
if self.playable()
|
||||
&& let Some(id) = self.selected_key().map(str::to_owned)
|
||||
{
|
||||
if !self.checked.remove(&id) {
|
||||
self.checked.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn playback_videos(&self) -> Vec<Video> {
|
||||
if self.checked.is_empty() {
|
||||
return self.selected_video().into_iter().collect();
|
||||
}
|
||||
match &self.content {
|
||||
Content::Series {
|
||||
media,
|
||||
data: Some(data),
|
||||
} => data
|
||||
.iter()
|
||||
.filter(|episode| self.checked.contains(&episode.identity.key()))
|
||||
.map(|episode| episode.snapshot(media))
|
||||
.collect(),
|
||||
Content::Collection { videos, .. } => videos
|
||||
.iter()
|
||||
.filter(|video| self.checked.contains(&video.id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn selected_key(&self) -> Option<&str> {
|
||||
self.selection
|
||||
.selected()
|
||||
@@ -265,7 +303,7 @@ impl Screen {
|
||||
self.rows = videos.iter().map(|video| {
|
||||
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
|
||||
let title = safe(&video.title);
|
||||
let metadata = format!("{title}\n\nEpisode {} · Miruro ID {}\n\nStored offline episode metadata. Enter resolves a fresh direct Sun source; no catalog slug is reconstructed from this title.", identity.episode, identity.media_id);
|
||||
let metadata = format!("{title}\n\nEpisode {} · Miruro ID {}\n\nStored offline episode metadata. Enter sends the episode URL to Terebi, which resolves sources and playback metadata.", identity.episode, identity.media_id);
|
||||
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
|
||||
}).collect();
|
||||
}
|
||||
@@ -289,16 +327,24 @@ impl Screen {
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if !self.checked.is_empty() {
|
||||
let visible: BTreeSet<_> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|row| row.key.as_deref())
|
||||
.collect();
|
||||
self.checked.retain(|id| visible.contains(id.as_str()));
|
||||
}
|
||||
self.update_description();
|
||||
}
|
||||
}
|
||||
|
||||
// History owns the actual screens, so back/forward never has to refetch a catalog.
|
||||
struct Browser {
|
||||
pub(crate) struct Browser {
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
state: State,
|
||||
pub(crate) state: State,
|
||||
screens: Vec<Screen>,
|
||||
index: usize,
|
||||
input: Option<String>,
|
||||
@@ -317,7 +363,6 @@ struct Browser {
|
||||
enum Intent {
|
||||
Catalog,
|
||||
Series(String),
|
||||
Source(String),
|
||||
}
|
||||
|
||||
enum LibraryModal {
|
||||
@@ -381,7 +426,12 @@ impl Picker {
|
||||
}
|
||||
|
||||
impl Browser {
|
||||
fn new(options: Options, config_path: PathBuf, config: config::Config, state: State) -> Self {
|
||||
pub(crate) fn new(
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
state: State,
|
||||
) -> Self {
|
||||
let mut query = String::new();
|
||||
crate::paste_input(&mut query, options.query.as_deref().unwrap_or(""));
|
||||
let mut browser = Self {
|
||||
@@ -502,7 +552,9 @@ impl Browser {
|
||||
return;
|
||||
};
|
||||
let id = media.id.clone();
|
||||
match miruro_browser::slug(&media.title) {
|
||||
let slug = miruro_browser::slug(&media.title);
|
||||
self.screen_mut().checked.clear();
|
||||
match slug {
|
||||
Ok(slug) => {
|
||||
self.request(
|
||||
Request::Episodes {
|
||||
@@ -525,13 +577,34 @@ impl Browser {
|
||||
{
|
||||
self.push(Screen::new(Content::Series { media, data: None }));
|
||||
self.load_series();
|
||||
} else if let Some(video) = self.screen().selected_video() {
|
||||
if Identity::from_key(&video.id).is_err() {
|
||||
} else {
|
||||
self.play(false);
|
||||
}
|
||||
}
|
||||
fn play(&mut self, enqueue: bool) {
|
||||
if self.screen().playable() {
|
||||
let videos = self.screen().playback_videos();
|
||||
if videos.is_empty() {
|
||||
return;
|
||||
}
|
||||
let id = video.id.clone();
|
||||
self.request(Request::Source(video), Intent::Source(id));
|
||||
self.status = "Resolving fresh direct Sun source…".into();
|
||||
self.invalidate();
|
||||
self.screen_mut().error = None;
|
||||
// Dropping the launcher cancels only its CLI child, not the native player.
|
||||
self.player = None;
|
||||
let result =
|
||||
PlayerLaunch::start(&videos, &self.options.player, &mut self.state, enqueue);
|
||||
match result {
|
||||
Ok(player) => {
|
||||
self.player = Some(player);
|
||||
self.screen_mut().checked.clear();
|
||||
self.status = if videos.len() == 1 {
|
||||
format!("Handing {} to Terebi…", safe(&videos[0].title))
|
||||
} else {
|
||||
format!("Handing {} episodes to Terebi…", videos.len())
|
||||
};
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fail(&mut self, error: impl std::fmt::Display) {
|
||||
@@ -589,40 +662,13 @@ impl Browser {
|
||||
current.extend(incoming);
|
||||
}
|
||||
*data = Some(series.episodes);
|
||||
self.screen_mut().checked.clear();
|
||||
self.screen_mut().selection.select(next);
|
||||
self.screen_mut().clamp();
|
||||
self.screen_mut().rebuild_display();
|
||||
self.status.clear();
|
||||
}
|
||||
}
|
||||
(Intent::Source(id), Ok(Response::Source(video, source))) => {
|
||||
if video.id != id || self.screen().selected_key() != Some(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
if source.identity.key() != id {
|
||||
self.fail(io::Error::other(
|
||||
"Miruro source identity did not match the selected episode",
|
||||
));
|
||||
return;
|
||||
}
|
||||
match self.state.miruro_session(&video, &source).and_then(
|
||||
|(source_file, progress_file)| {
|
||||
// Dropping the launcher cancels only its CLI child, not the native player.
|
||||
self.player = None;
|
||||
PlayerLaunch::start_source(
|
||||
&source_file,
|
||||
&self.options.player,
|
||||
&progress_file,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Ok(player) => {
|
||||
self.player = Some(player);
|
||||
self.status = format!("Opening {} in Terebi…", safe(&video.title));
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
}
|
||||
(_, Err(error)) => self.fail(error),
|
||||
_ => self.fail(io::Error::other(
|
||||
"Miruro response did not match the current request",
|
||||
@@ -910,12 +956,6 @@ impl Browser {
|
||||
screen.clamp();
|
||||
screen.rebuild_display();
|
||||
}
|
||||
// Progress may remove the originating currently-watching row during resolution.
|
||||
if let Some(Intent::Source(id)) = &self.intent
|
||||
&& self.screen().selected_key() != Some(id.as_str())
|
||||
{
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
fn save(&mut self, kind: CollectionKind) {
|
||||
let Some(video) = self.screen().selected_video() else {
|
||||
@@ -1003,6 +1043,7 @@ impl Browser {
|
||||
}
|
||||
fn refresh(&mut self) {
|
||||
self.invalidate();
|
||||
self.screen_mut().checked.clear();
|
||||
match config::load(&self.config_path) {
|
||||
Ok(config) => {
|
||||
self.config = config;
|
||||
@@ -1064,10 +1105,6 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn select(&mut self, action: Action) {
|
||||
if matches!(self.intent, Some(Intent::Source(_))) {
|
||||
self.invalidate();
|
||||
self.status.clear();
|
||||
}
|
||||
let screen = self.screen_mut();
|
||||
let last = screen.len().saturating_sub(1);
|
||||
let selected = screen.selection.selected().unwrap_or(0);
|
||||
@@ -1179,6 +1216,14 @@ impl Browser {
|
||||
Some(Action::ViewNext) => self.history(true),
|
||||
Some(Action::ViewRefresh) => self.refresh(),
|
||||
Some(Action::VideoPlay) => self.open(),
|
||||
Some(Action::VideoEnqueue) => self.play(true),
|
||||
Some(Action::VideoToggle) => self.screen_mut().toggle_checked(),
|
||||
Some(Action::VideoClear) => self.screen_mut().checked.clear(),
|
||||
Some(Action::VideoCopyUrl) if self.screen().playable() => {
|
||||
if let Some(id) = self.screen().selected_key() {
|
||||
self.status = ui::copy_video_url(id);
|
||||
}
|
||||
}
|
||||
Some(
|
||||
action @ (Action::VideoNext
|
||||
| Action::VideoPrevious
|
||||
@@ -1291,8 +1336,10 @@ impl Browser {
|
||||
Content::Catalog { .. } | Content::Library { .. }
|
||||
) {
|
||||
"Open series"
|
||||
} else if self.screen().checked.is_empty() {
|
||||
"Play episode in Terebi"
|
||||
} else {
|
||||
"Play episode (fresh source)"
|
||||
"Play checked episodes in Terebi"
|
||||
},
|
||||
),
|
||||
(Action::VideoNext, "Next title/episode"),
|
||||
@@ -1310,6 +1357,7 @@ impl Browser {
|
||||
"Show series/episode info with cover art",
|
||||
),
|
||||
(Action::Help, "Keyboard help"),
|
||||
(Action::ProviderChoose, "Choose provider"),
|
||||
(Action::Quit, "Exit"),
|
||||
(Action::Interrupt, "Exit immediately"),
|
||||
];
|
||||
@@ -1334,6 +1382,13 @@ impl Browser {
|
||||
Content::Series { .. } | Content::Collection { .. }
|
||||
) {
|
||||
bindings.extend([
|
||||
(
|
||||
Action::VideoEnqueue,
|
||||
"Append checked or selected episodes to Terebi's queue",
|
||||
),
|
||||
(Action::VideoToggle, "Toggle checked episode"),
|
||||
(Action::VideoClear, "Clear checked episodes"),
|
||||
(Action::VideoCopyUrl, "Copy selected episode URL"),
|
||||
(Action::WatchLaterSave, "Save episode to watch later"),
|
||||
(Action::SavedVideoSave, "Save episode"),
|
||||
(Action::WatchStatusWatching, "Mark episode as watching"),
|
||||
@@ -1391,12 +1446,21 @@ impl Browser {
|
||||
Content::Catalog { .. } | Content::Library { .. }
|
||||
) {
|
||||
"open"
|
||||
} else {
|
||||
} else if self.screen().checked.is_empty() {
|
||||
"play"
|
||||
} else {
|
||||
"play checked"
|
||||
},
|
||||
),
|
||||
(Action::CategoryBrowse, "categories"),
|
||||
];
|
||||
if self.screen().playable() {
|
||||
actions.extend([
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
]);
|
||||
}
|
||||
if !matches!(self.screen().content, Content::Collection { .. }) {
|
||||
actions.push((Action::ChannelTags, "save show/tags"));
|
||||
}
|
||||
@@ -1445,6 +1509,10 @@ impl Browser {
|
||||
"INFO",
|
||||
&[
|
||||
(Action::InfoDescription, "description"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
(Action::InfoHide, "close"),
|
||||
(Action::Help, "help"),
|
||||
],
|
||||
@@ -1454,6 +1522,10 @@ impl Browser {
|
||||
"DESCRIPTION",
|
||||
&[
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::VideoToggle, "check"),
|
||||
(Action::VideoClear, "clear checks"),
|
||||
(Action::InfoHide, "close"),
|
||||
(Action::Help, "help"),
|
||||
(Action::WatchStatusWatching, "mark watching"),
|
||||
@@ -1468,7 +1540,9 @@ impl Browser {
|
||||
"NORMAL",
|
||||
&[
|
||||
(Action::Help, "help"),
|
||||
(Action::ProviderChoose, "provider"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::WatchStatusWatching, "mark watching"),
|
||||
(Action::WatchStatusWatched, "mark watched"),
|
||||
@@ -1484,6 +1558,7 @@ impl Browser {
|
||||
"NORMAL",
|
||||
&[
|
||||
(Action::Help, "help"),
|
||||
(Action::ProviderChoose, "provider"),
|
||||
(Action::SearchEdit, "search"),
|
||||
(
|
||||
Action::VideoPlay,
|
||||
@@ -1493,6 +1568,7 @@ impl Browser {
|
||||
"play"
|
||||
},
|
||||
),
|
||||
(Action::VideoEnqueue, "enqueue"),
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::WatchStatusWatching, "mark watching"),
|
||||
(Action::WatchStatusWatched, "mark watched"),
|
||||
@@ -1519,7 +1595,7 @@ impl Browser {
|
||||
fn markers(&self, id: &str) -> String {
|
||||
let mut markers = String::new();
|
||||
for (present, label) in [
|
||||
(self.state.is_watched(id), "✓ watched"),
|
||||
(self.state.is_watched(id), "watched"),
|
||||
(
|
||||
self.state.contains(CollectionKind::WatchLater, id),
|
||||
"watch later",
|
||||
@@ -1598,7 +1674,29 @@ impl Browser {
|
||||
" · "
|
||||
};
|
||||
ListItem::new(vec![
|
||||
Line::from(row.title.as_str()).style(style),
|
||||
Line::from(vec![
|
||||
ratatui::text::Span::raw(if self.screen().playable() {
|
||||
if row
|
||||
.key
|
||||
.as_ref()
|
||||
.is_some_and(|id| self.screen().checked.contains(id))
|
||||
{
|
||||
"* "
|
||||
} else if row
|
||||
.key
|
||||
.as_deref()
|
||||
.is_some_and(|id| self.state.is_watched(id))
|
||||
{
|
||||
"✓ "
|
||||
} else {
|
||||
" "
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}),
|
||||
ratatui::text::Span::raw(row.title.as_str()),
|
||||
])
|
||||
.style(style),
|
||||
Line::from(vec![
|
||||
ratatui::text::Span::raw(row.details.as_str()),
|
||||
ratatui::text::Span::raw(separator),
|
||||
@@ -1650,12 +1748,13 @@ impl Browser {
|
||||
query,
|
||||
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
|
||||
);
|
||||
let title = match self.screen().content {
|
||||
Content::Catalog { .. } | Content::Library { .. } => " Titles ",
|
||||
_ => " Episodes ",
|
||||
let title = if self.screen().playable() {
|
||||
format!(" Episodes · {} checked ", self.screen().checked.len())
|
||||
} else {
|
||||
" Titles ".into()
|
||||
};
|
||||
let block = Block::bordered().title(crate::loading_title(
|
||||
title,
|
||||
&title,
|
||||
"",
|
||||
self.intent.is_some().then(|| ui::spinner(elapsed)),
|
||||
));
|
||||
@@ -1804,7 +1903,7 @@ impl Browser {
|
||||
let mut lines = vec![
|
||||
Line::from("Miruro catalog → series → actual episodes").bold(),
|
||||
Line::from(
|
||||
"No provider/track fallback; fresh direct ssub/Sun HLS + VTT on every play.",
|
||||
"Terebi resolves episode URLs, sources, subtitles and playback metadata.",
|
||||
),
|
||||
Line::from("✓ is acknowledged playback progress, never CLI completion."),
|
||||
Line::default(),
|
||||
@@ -2071,44 +2170,44 @@ fn media_details(media: &Media) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
terminal: &mut DefaultTerminal,
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
state: State,
|
||||
) -> io::Result<()> {
|
||||
struct RestoreTerminalInput;
|
||||
impl Drop for RestoreTerminalInput {
|
||||
fn drop(&mut self) {
|
||||
let _ = execute!(
|
||||
io::stdout(),
|
||||
DisableBracketedPaste,
|
||||
SetCursorStyle::DefaultUserShape
|
||||
);
|
||||
impl Browser {
|
||||
pub(crate) fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<Option<bool>> {
|
||||
struct RestoreTerminalInput;
|
||||
impl Drop for RestoreTerminalInput {
|
||||
fn drop(&mut self) {
|
||||
let _ = execute!(
|
||||
io::stdout(),
|
||||
DisableBracketedPaste,
|
||||
SetCursorStyle::DefaultUserShape
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _restore = RestoreTerminalInput;
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnableBracketedPaste,
|
||||
SetCursorStyle::SteadyBlock
|
||||
)?;
|
||||
let mut browser = Browser::new(options, config_path, config, state);
|
||||
browser.thumbnail.query_terminal();
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
browser.update();
|
||||
terminal.draw(|frame| browser.draw(frame, started.elapsed()))?;
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if browser.key(key) {
|
||||
return Ok(());
|
||||
let _restore = RestoreTerminalInput;
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnableBracketedPaste,
|
||||
SetCursorStyle::SteadyBlock
|
||||
)?;
|
||||
self.thumbnail.query_terminal();
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
self.update();
|
||||
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if self.config.keys.resolve(self.context(), key)
|
||||
== Some(Action::ProviderChoose)
|
||||
{
|
||||
return crate::choose_provider(terminal, &self.config.keys, Some(true));
|
||||
}
|
||||
if self.key(key) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => self.paste(&text),
|
||||
_ => {}
|
||||
}
|
||||
Event::Paste(text) => browser.paste(&text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ use std::time::Duration;
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
crossterm::{
|
||||
clipboard::CopyToClipboard,
|
||||
event::{
|
||||
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
},
|
||||
execute,
|
||||
},
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
@@ -12,6 +19,34 @@ pub const ACCENT: Style = Style::new().fg(Color::Cyan);
|
||||
pub const SELECTED: Style = Style::new().fg(Color::Black).bg(Color::Cyan);
|
||||
pub const SELECTION_MARKER: &str = "> ";
|
||||
|
||||
// Keep modified Return distinct from Return on terminals supporting the Kitty protocol.
|
||||
pub struct KeyboardProtocol;
|
||||
|
||||
impl KeyboardProtocol {
|
||||
pub fn enable() -> std::io::Result<Self> {
|
||||
execute!(
|
||||
std::io::stdout(),
|
||||
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||
)?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for KeyboardProtocol {
|
||||
fn drop(&mut self) {
|
||||
let _ = execute!(std::io::stdout(), PopKeyboardEnhancementFlags);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_video_url(id: &str) -> String {
|
||||
let result = crate::youtube::video_url(id)
|
||||
.and_then(|url| execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from(url)));
|
||||
match result {
|
||||
Ok(()) => "Video URL sent to terminal clipboard.".into(),
|
||||
Err(error) => format!("Cannot copy video URL: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn browser_layout(area: Rect, header_height: u16) -> [Rect; 5] {
|
||||
Layout::vertical([
|
||||
Constraint::Length(header_height),
|
||||
|
||||
+23
-23
@@ -10,13 +10,12 @@ 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};
|
||||
use tubular::miruro::Identity;
|
||||
|
||||
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_SHOWS: usize = 4096;
|
||||
const MAX_SHOW_TAGS: usize = 256;
|
||||
const MAX_TAG_BYTES: usize = 256;
|
||||
@@ -347,6 +346,23 @@ impl State {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn progress_files(&mut self, videos: &[Video]) -> io::Result<Vec<PathBuf>> {
|
||||
if videos.len() > MAX_STREAMS.saturating_sub(self.streams.len()) {
|
||||
return Err(invalid(
|
||||
"Playback session limit reached; remove inactive playback-sessions directories and restart",
|
||||
));
|
||||
}
|
||||
// Validate the entire batch before creating any retained sessions.
|
||||
let mut snapshots = videos.to_vec();
|
||||
for video in &mut snapshots {
|
||||
validate_snapshot(video, false)?;
|
||||
}
|
||||
snapshots
|
||||
.iter()
|
||||
.map(|video| self.create_progress_file(video))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
|
||||
if self.streams.len() >= MAX_STREAMS {
|
||||
return Err(invalid(
|
||||
@@ -355,6 +371,10 @@ impl State {
|
||||
}
|
||||
let mut video = video.clone();
|
||||
validate_snapshot(&mut video, false)?;
|
||||
self.create_progress_file(&video)
|
||||
}
|
||||
|
||||
fn create_progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
|
||||
fs::create_dir_all(self.sessions.parent().unwrap_or(Path::new(".")))?;
|
||||
private_directory(&self.sessions, true)?;
|
||||
let directory = loop {
|
||||
@@ -370,32 +390,12 @@ impl State {
|
||||
}
|
||||
};
|
||||
let (mut snapshot, _) = StoredFile::open(directory.join("video.toml"))?;
|
||||
snapshot.save(video_document(std::iter::once(&video)))?;
|
||||
snapshot.save(video_document(std::iter::once(video)))?;
|
||||
let path = directory.join("progress.jsonl");
|
||||
self.streams.push(ProgressStream::new(path.clone()));
|
||||
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<bool> {
|
||||
let mut error = self.discover().err();
|
||||
let count = self.streams.len().min(16);
|
||||
|
||||
+86
-60
@@ -1,8 +1,9 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
collections::{BTreeSet, VecDeque},
|
||||
ffi::OsStr,
|
||||
fs::{self, OpenOptions},
|
||||
io::{self, Read},
|
||||
path::Path,
|
||||
path::PathBuf,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::mpsc::{self, Receiver, TryRecvError},
|
||||
thread,
|
||||
@@ -862,6 +863,16 @@ fn failure(errors: &Receiver<io::Result<String>>, fallback: String) -> Option<Re
|
||||
}
|
||||
}
|
||||
|
||||
pub fn video_url(id: &str) -> io::Result<String> {
|
||||
if valid_video_id(id) {
|
||||
Ok(format!("https://www.youtube.com/watch?v={id}"))
|
||||
} else {
|
||||
tubular::miruro::Identity::from_key(id)
|
||||
.and_then(|identity| identity.watch_url())
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> {
|
||||
if !valid_video_id(id) {
|
||||
return Err(io::Error::new(
|
||||
@@ -875,7 +886,7 @@ pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> {
|
||||
"xdg-open"
|
||||
};
|
||||
let child = isolated_command(OsStr::new(opener))
|
||||
.arg(format!("https://www.youtube.com/watch?v={id}"))
|
||||
.arg(video_url(id)?)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -914,70 +925,85 @@ pub struct PlayerLaunch {
|
||||
}
|
||||
|
||||
impl PlayerLaunch {
|
||||
pub fn start(id: &str, player: &OsStr, progress_file: &Path) -> io::Result<Self> {
|
||||
if !valid_video_id(id) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Invalid YouTube video ID",
|
||||
));
|
||||
}
|
||||
crate::video_state::register_identity(progress_file, id)?;
|
||||
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);
|
||||
Self::launch(command)
|
||||
}
|
||||
|
||||
/// Session files are owned and retained by video_state, not by this launcher.
|
||||
pub fn start_source(
|
||||
source_file: &Path,
|
||||
pub fn start(
|
||||
videos: &[Video],
|
||||
player: &OsStr,
|
||||
progress_file: &Path,
|
||||
state: &mut crate::video_state::State,
|
||||
enqueue: bool,
|
||||
) -> io::Result<Self> {
|
||||
if !std::fs::symlink_metadata(source_file)?.is_file() {
|
||||
if videos.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Source document must be a regular file",
|
||||
"No videos selected",
|
||||
));
|
||||
}
|
||||
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);
|
||||
command.arg(if enqueue {
|
||||
"--enqueue"
|
||||
} else {
|
||||
"--new-instance"
|
||||
});
|
||||
if let [video] = videos
|
||||
&& !enqueue
|
||||
{
|
||||
let url = video_url(&video.id)?;
|
||||
let report = state.progress_file(video)?;
|
||||
crate::video_state::register_identity(&report, &video.id)?;
|
||||
command
|
||||
.args(["--url", &url])
|
||||
.arg("--progress-file")
|
||||
.arg(report);
|
||||
return Self::launch(command);
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
let urls = videos
|
||||
.iter()
|
||||
.map(|video| {
|
||||
if !ids.insert(video.id.as_str()) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Duplicate queue identity",
|
||||
));
|
||||
}
|
||||
video_url(&video.id)
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
let reports = state.progress_files(videos)?;
|
||||
#[derive(Serialize)]
|
||||
struct QueueItem {
|
||||
url: String,
|
||||
progress_file: PathBuf,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
struct Queue {
|
||||
version: u8,
|
||||
items: Vec<QueueItem>,
|
||||
}
|
||||
let items = videos
|
||||
.iter()
|
||||
.zip(urls)
|
||||
.zip(reports)
|
||||
.map(|((video, url), report)| {
|
||||
crate::video_state::register_identity(&report, &video.id)?;
|
||||
let parent = fs::canonicalize(report.parent().expect("session directory"))?;
|
||||
Ok(QueueItem {
|
||||
url,
|
||||
progress_file: parent.join("progress.jsonl"),
|
||||
})
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
let manifest = items[0].progress_file.with_file_name("queue.json");
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let file = options.open(&manifest)?;
|
||||
serde_json::to_writer(&file, &Queue { version: 1, items })?;
|
||||
file.sync_all()?;
|
||||
command.arg("--queue-file").arg(manifest);
|
||||
Self::launch(command)
|
||||
}
|
||||
|
||||
|
||||
+217
-60
@@ -67,6 +67,203 @@ fn app(args: &[&str]) -> App {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.view_index = 1;
|
||||
app.feed_channels = vec!["https://www.youtube.com/@fixture".into()];
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"New","timestamp":200}]}"#,
|
||||
true,
|
||||
);
|
||||
let view = &mut app.views[1];
|
||||
view.selection.select(Some(1));
|
||||
view.toggle_checked();
|
||||
view.selection.select(Some(0));
|
||||
view.toggle_checked();
|
||||
assert_eq!(
|
||||
view.playback_videos()
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
|
||||
);
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Newest","timestamp":300}]}"#,
|
||||
false,
|
||||
);
|
||||
let view = &mut app.views[1];
|
||||
assert_eq!(view.selection.selected(), Some(1));
|
||||
assert_eq!(
|
||||
view.playback_videos()
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
|
||||
);
|
||||
view.reset();
|
||||
assert!(view.checked.is_empty());
|
||||
// Old rows remain during refresh, but checks made there cannot leak into replacement data.
|
||||
view.toggle_checked();
|
||||
assert!(!view.checked.is_empty());
|
||||
view.accept_page(
|
||||
youtube::search_results(r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Replacement"}]}"#)
|
||||
.unwrap(),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(view.checked.is_empty());
|
||||
assert_eq!(view.playback_videos()[0].title, "Replacement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections() {
|
||||
let mut app = app(&["tubular"]);
|
||||
let videos = youtube::search_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@creator"}]}"#,
|
||||
).unwrap().videos;
|
||||
app.views[0].videos = videos.clone();
|
||||
app.views[0].selection.select(Some(0));
|
||||
app.views[0].loaded = true;
|
||||
app.views[1].loaded = true;
|
||||
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
app.switch_view(1);
|
||||
assert!(app.views[1].checked.is_empty());
|
||||
app.switch_view(0);
|
||||
assert!(app.views[0].checked.contains(&videos[0].id));
|
||||
app.mode = Mode::Insert;
|
||||
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(app.views[0].checked.len(), 1);
|
||||
app.mode = Mode::Normal;
|
||||
for preview in [
|
||||
None,
|
||||
Some(PreviewMode::Info),
|
||||
Some(PreviewMode::Description),
|
||||
] {
|
||||
app.preview = preview;
|
||||
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
|
||||
assert!(app.views[0].checked.is_empty());
|
||||
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert!(app.views[0].checked.contains(&videos[0].id));
|
||||
}
|
||||
app.preview = None;
|
||||
for video in &videos {
|
||||
app.video_state
|
||||
.save(CollectionKind::WatchLater, video)
|
||||
.unwrap();
|
||||
}
|
||||
app.browse_collection(CollectionKind::WatchLater);
|
||||
let index = app.view_index;
|
||||
app.views[index].selection.select(Some(0));
|
||||
app.views[index].toggle_checked();
|
||||
app.views[index].selection.select(Some(1));
|
||||
app.views[index].toggle_checked();
|
||||
let removed = app.views[index].videos[1].id.clone();
|
||||
app.remove_collection();
|
||||
assert!(!app.views[index].checked.contains(&removed));
|
||||
assert_eq!(app.views[index].checked.len(), 1);
|
||||
assert_eq!(app.views[index].playback_videos().len(), 1);
|
||||
app.views[index].watch_filter = WatchFilter::Watched;
|
||||
app.rebuild_collections();
|
||||
assert!(app.views[index].videos.is_empty());
|
||||
assert!(app.views[index].checked.is_empty());
|
||||
app.views[index].watch_filter = WatchFilter::All;
|
||||
app.rebuild_collections();
|
||||
app.views[index].toggle_checked();
|
||||
// Refresh is a replacement even for locally rebuilt collection views.
|
||||
app.load_view();
|
||||
assert!(app.views[index].checked.is_empty());
|
||||
assert_eq!(app.views[0].checked.len(), 1);
|
||||
// An empty category exercises query replacement entirely offline.
|
||||
app.config.categories.insert("Empty".into(), Vec::new());
|
||||
let mut view = View::new("Empty".into(), ViewSource::Category("Empty".into()));
|
||||
view.videos = videos;
|
||||
view.selection.select(Some(0));
|
||||
view.toggle_checked();
|
||||
view.query = "replacement query".into();
|
||||
app.views.push(view);
|
||||
app.view_index = app.views.len() - 1;
|
||||
app.search();
|
||||
assert!(app.views[app.view_index].checked.is_empty());
|
||||
assert!(app.job.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
|
||||
let mut app = app(&["tubular"]);
|
||||
let document =
|
||||
"[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n[keys.video]\nenqueue = 'f22'\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
|
||||
app.key_hints = KeyHints::new(&app.config.keys);
|
||||
for context in [
|
||||
Context::Normal,
|
||||
Context::Info,
|
||||
Context::Description,
|
||||
Context::CollectionNormal,
|
||||
Context::CollectionInfo,
|
||||
Context::CollectionDescription,
|
||||
] {
|
||||
for key in ["F20", "F21", "F22"] {
|
||||
assert!(app.key_hints.footer(context).contains(key));
|
||||
assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key)));
|
||||
assert!(app.config.keys.help_text().to_string().contains(key));
|
||||
}
|
||||
}
|
||||
app.views[0].videos = youtube::search_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Checked"},{"id":"rQ_J9WH6CGk","title":"Unchecked"}]}"#,
|
||||
).unwrap().videos;
|
||||
for (index, video) in app.views[0].videos.iter_mut().enumerate() {
|
||||
video.id = format!("offline:{index}");
|
||||
}
|
||||
app.views[0].selection.select(Some(0));
|
||||
app.key(KeyEvent::new(KeyCode::F(20), KeyModifiers::NONE));
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| app.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let rendered: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(rendered.contains("1 checked"));
|
||||
app.key(KeyEvent::new(KeyCode::F(21), KeyModifiers::NONE));
|
||||
assert!(app.views[0].checked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() {
|
||||
for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.views[0].videos = youtube::search_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#,
|
||||
).unwrap().videos;
|
||||
app.views[0].selection.select(Some(1));
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
||||
app.views[0].toggle_checked();
|
||||
app.views[0].selection.select(Some(0));
|
||||
app.options.player = "/nonexistent/tubular-test-player".into();
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(app.player_launch.is_none());
|
||||
assert!(app.views[0].checked.contains("rQ_J9WH6CGk"));
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
||||
app.options.player = "/usr/bin/true".into();
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(app.player_launch.is_some());
|
||||
assert!(app.views[0].checked.is_empty());
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw");
|
||||
app.player_launch = None;
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(app.player_launch.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
|
||||
let mut app = app(&["tubular"]);
|
||||
@@ -1849,62 +2046,6 @@ fn collection_contexts_preserve_modal_priorities_and_cancel_bulk_from_each_previ
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_removal_hints_follow_collection_views_and_previews() {
|
||||
let mut app = app(&["tubular"]);
|
||||
let document =
|
||||
"[keys]\nview.collection.remove.selected = 'f8'\nview.collection.remove.bulk = 'f9'\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
app.apply_config(config::Config {
|
||||
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
|
||||
..Default::default()
|
||||
});
|
||||
app.views[0].videos = youtube::search_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"}]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.videos;
|
||||
app.views[0].loaded = true;
|
||||
app.views[0].selection.select(Some(0));
|
||||
app.save_collection(CollectionKind::WatchLater);
|
||||
app.save_collection(CollectionKind::Saved);
|
||||
app.remember_description("jNQXAC9IVRw".into(), "Cached description.".into());
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(320, 80)).unwrap();
|
||||
for kind in [
|
||||
None,
|
||||
Some(CollectionKind::WatchLater),
|
||||
Some(CollectionKind::Saved),
|
||||
] {
|
||||
app.switch_view(0);
|
||||
if let Some(kind) = kind {
|
||||
app.browse_collection(kind);
|
||||
}
|
||||
for preview in [
|
||||
None,
|
||||
Some(PreviewMode::Info),
|
||||
Some(PreviewMode::Description),
|
||||
] {
|
||||
app.preview = preview;
|
||||
terminal
|
||||
.draw(|frame| app.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
|
||||
assert_eq!(rendered.contains("F8"), kind.is_some());
|
||||
assert_eq!(rendered.contains("F9"), kind.is_some());
|
||||
if preview == Some(PreviewMode::Info) {
|
||||
let popup = preview_area(buffer.area);
|
||||
let title: String = (popup.x..popup.right())
|
||||
.map(|x| buffer[(x, popup.y)].symbol())
|
||||
.collect();
|
||||
assert_eq!(title.contains("F8"), kind.is_some());
|
||||
assert_eq!(title.contains("F9"), kind.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_removal_snapshots_saved_categories_and_watch_filters_independently() {
|
||||
let root = std::env::temp_dir().join(format!("tubular-collection-bulk-{}", std::process::id()));
|
||||
@@ -3496,7 +3637,7 @@ fn forward_navigation_skips_unsubscribed_views() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_and_minus_restore_view_history_without_consuming_modal_escape() {
|
||||
fn minus_restores_view_history_without_consuming_modal_escape() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.apply_config(config::Config {
|
||||
categories: [("Category".into(), Vec::new())].into(),
|
||||
@@ -3537,7 +3678,7 @@ fn escape_and_minus_restore_view_history_without_consuming_modal_escape() {
|
||||
app.views[app.view_index].source,
|
||||
ViewSource::Channel("beta".into())
|
||||
);
|
||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
app.views[app.view_index].source,
|
||||
ViewSource::Channel("alpha".into())
|
||||
@@ -3553,7 +3694,7 @@ fn escape_and_minus_restore_view_history_without_consuming_modal_escape() {
|
||||
app.views[app.view_index].source,
|
||||
ViewSource::Category("Category".into())
|
||||
);
|
||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
||||
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
||||
@@ -3589,7 +3730,7 @@ fn back_history_uses_source_identity_after_config_reorders_or_removes_views() {
|
||||
channels: [("@Alpha".into(), "alpha".into())].into(),
|
||||
..Default::default()
|
||||
});
|
||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
||||
assert!(app.job.is_none());
|
||||
}
|
||||
@@ -5921,3 +6062,19 @@ fn search_without_a_handle_can_tag_a_registered_channel() {
|
||||
);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_discards_checks_without_consuming_view_history() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.views[0].loaded = true;
|
||||
app.views[1].loaded = true;
|
||||
app.switch_view(1);
|
||||
app.views[1].checked.insert("jNQXAC9IVRw".into());
|
||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(app.views[1].checked.is_empty());
|
||||
assert_eq!(app.view_index, 1);
|
||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert_eq!(app.view_index, 1);
|
||||
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
||||
assert_eq!(app.view_index, 0);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,111 @@ fn press(
|
||||
bindings.resolve(context, KeyEvent::new(code, modifiers))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_are_configurable_in_views_without_stealing_modal_space() {
|
||||
let defaults = Bindings::default();
|
||||
let overrides = configured("[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n").unwrap();
|
||||
for &context in ALL {
|
||||
for (action, modifiers, override_code) in [
|
||||
(Action::VideoToggle, KeyModifiers::NONE, KeyCode::F(20)),
|
||||
(Action::VideoClear, KeyModifiers::CONTROL, KeyCode::F(21)),
|
||||
] {
|
||||
let expected = VIEW.contains(&context).then_some(action);
|
||||
assert_eq!(
|
||||
press(&defaults, context, KeyCode::Char(' '), modifiers)
|
||||
.filter(|resolved| *resolved == action),
|
||||
expected,
|
||||
);
|
||||
assert_eq!(
|
||||
press(&overrides, context, override_code, KeyModifiers::NONE),
|
||||
expected,
|
||||
);
|
||||
assert_ne!(
|
||||
press(&overrides, context, KeyCode::Char(' '), modifiers),
|
||||
Some(action),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
press(
|
||||
&defaults,
|
||||
Context::TagList,
|
||||
KeyCode::Char(' '),
|
||||
KeyModifiers::NONE
|
||||
),
|
||||
Some(Action::TagToggle),
|
||||
);
|
||||
assert_eq!(
|
||||
press(
|
||||
&defaults,
|
||||
Context::SelectorNormal,
|
||||
KeyCode::Char(' '),
|
||||
KeyModifiers::NONE
|
||||
),
|
||||
Some(Action::SelectorToggle),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_is_configurable_and_only_active_in_video_views() {
|
||||
let defaults = Bindings::default();
|
||||
let overrides = configured("[keys.video]\nenqueue = 'f22'\n").unwrap();
|
||||
for &context in ALL {
|
||||
let expected = VIEW.contains(&context).then_some(Action::VideoEnqueue);
|
||||
for modifiers in [KeyModifiers::CONTROL] {
|
||||
assert_eq!(
|
||||
press(&defaults, context, KeyCode::Enter, modifiers),
|
||||
expected
|
||||
);
|
||||
assert_ne!(
|
||||
press(&overrides, context, KeyCode::Enter, modifiers),
|
||||
Some(Action::VideoEnqueue),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
press(&defaults, context, KeyCode::Enter, KeyModifiers::SUPER),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
press(&overrides, context, KeyCode::F(22), KeyModifiers::NONE),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
press(
|
||||
&defaults,
|
||||
Context::Normal,
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE
|
||||
),
|
||||
Some(Action::VideoPlay),
|
||||
);
|
||||
assert!(configured("[keys.video]\nenqueue = 'enter'\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_choice_is_configurable_and_normal_only() {
|
||||
let defaults = Bindings::default();
|
||||
let overrides = configured("[keys.app]\nprovider = 'alt+p'\n").unwrap();
|
||||
for &context in ALL {
|
||||
let expected = matches!(context, Context::Normal | Context::CollectionNormal)
|
||||
.then_some(Action::ProviderChoose);
|
||||
assert_eq!(
|
||||
press(&defaults, context, KeyCode::Char('p'), KeyModifiers::NONE)
|
||||
.filter(|action| *action == Action::ProviderChoose),
|
||||
expected,
|
||||
);
|
||||
assert_eq!(
|
||||
press(&overrides, context, KeyCode::Char('p'), KeyModifiers::ALT),
|
||||
expected,
|
||||
);
|
||||
assert_ne!(
|
||||
press(&overrides, context, KeyCode::Char('p'), KeyModifiers::NONE),
|
||||
Some(Action::ProviderChoose),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_section_headers_are_bold_in_the_grouped_overview() {
|
||||
use ratatui::{
|
||||
|
||||
+33
-56
@@ -1,75 +1,52 @@
|
||||
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();
|
||||
fn watch_url_preserves_stable_identity_and_validates_mutable_fields() {
|
||||
let identity = Identity::from_key("miruro:OERe_a0YS4h-DlsBDOeXwX7HOO6316tw:1").unwrap();
|
||||
assert_eq!(identity.key(), "miruro:OERe_a0YS4h-DlsBDOeXwX7HOO6316tw:1");
|
||||
assert_eq!(
|
||||
identity.watch_url().unwrap(),
|
||||
"https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1"
|
||||
);
|
||||
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
|
||||
assert_ne!(
|
||||
Identity::new("catalog".into(), 2).unwrap().key(),
|
||||
source.identity.key()
|
||||
Identity::new(identity.media_id.clone(), 2).unwrap().key(),
|
||||
identity.key()
|
||||
);
|
||||
for key in [
|
||||
"miruro:catalog:01",
|
||||
"miruro:catalog:0",
|
||||
"miruro:catalog:2147483648",
|
||||
"miruro:catalog/:1",
|
||||
"miruro:catalog:+1",
|
||||
"miruro::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"),
|
||||
),
|
||||
for invalid in [
|
||||
Identity {
|
||||
provider: "youtube".into(),
|
||||
..identity.clone()
|
||||
},
|
||||
Identity {
|
||||
media_id: "catalog/".into(),
|
||||
..identity.clone()
|
||||
},
|
||||
Identity {
|
||||
media_id: "a".repeat(513),
|
||||
..identity.clone()
|
||||
},
|
||||
Identity {
|
||||
episode: 0,
|
||||
..identity.clone()
|
||||
},
|
||||
Identity {
|
||||
episode: i32::MAX as u32 + 1,
|
||||
..identity.clone()
|
||||
},
|
||||
] {
|
||||
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"));
|
||||
assert!(invalid.watch_url().is_err());
|
||||
}
|
||||
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]
|
||||
|
||||
+215
-128
@@ -1,12 +1,11 @@
|
||||
use super::*;
|
||||
use ratatui::crossterm::event::KeyModifiers;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::Write,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use tubular::miruro::{Detail, SourceDocument};
|
||||
use tubular::miruro::Detail;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
@@ -53,16 +52,6 @@ fn series(media: &Media) -> miruro_browser::Series {
|
||||
serde_json::json!({"episode_number":7, "absolute_number":53, "title":null, "duration_seconds":null}),
|
||||
] }).unwrap()
|
||||
}
|
||||
fn source(video: &Video, token: &str) -> SourceDocument {
|
||||
SourceDocument {
|
||||
version: 1,
|
||||
url: format!("https://vault.keeply.top/master.m3u8?token={token}"),
|
||||
headers: BTreeMap::new(),
|
||||
identity: Identity::from_key(&video.id).unwrap(),
|
||||
title: Some(video.title.clone()),
|
||||
subtitles: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn catalog_results(browser: &mut Browser) {
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
@@ -77,19 +66,6 @@ fn enter_series(browser: &mut Browser) {
|
||||
let result = series(media);
|
||||
browser.accept(browser.generation, Ok(Response::Series(result)));
|
||||
}
|
||||
fn sources(directory: &Directory) -> Vec<Value> {
|
||||
let path = directory.0.join("playback-sessions");
|
||||
if !path.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
fs::read_dir(path)
|
||||
.unwrap()
|
||||
.map(|entry| {
|
||||
serde_json::from_slice(&fs::read(entry.unwrap().path().join("source.json")).unwrap())
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_popup_scrolls_and_closes_without_moving_the_episode_selection() {
|
||||
@@ -188,7 +164,7 @@ fn catalog_series_back_forward_keep_native_query_selection_and_actual_episodes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_intent_and_navigation_reject_stale_catalog_series_and_sources() {
|
||||
fn latest_intent_and_navigation_reject_stale_catalog_and_series() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("old"));
|
||||
let old = browser.generation;
|
||||
@@ -214,30 +190,10 @@ fn latest_intent_and_navigation_reject_stale_catalog_series_and_sources() {
|
||||
};
|
||||
let data = series(media);
|
||||
browser.accept(browser.generation, Ok(Response::Series(data)));
|
||||
let video = browser.screen().selected_video().unwrap();
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.history(false);
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
video.clone(),
|
||||
Box::new(source(&video, "stale")),
|
||||
)),
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().unwrap().id,
|
||||
"miruro:first:3"
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
browser.history(true);
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.refresh();
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
video.clone(),
|
||||
Box::new(source(&video, "refreshed-away")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -274,12 +230,9 @@ fn manual_watch_status_refreshes_filters_and_preserves_other_providers() {
|
||||
assert!(!browser.state.is_watched(&episode.id));
|
||||
browser.preview = None;
|
||||
browser.collection(CollectionKind::Watching);
|
||||
browser.open();
|
||||
assert!(browser.pending.is_some());
|
||||
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(browser.pending.is_none());
|
||||
browser.collection(CollectionKind::WatchLater);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
@@ -336,8 +289,6 @@ fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
|
||||
}
|
||||
browser.collection(CollectionKind::Saved);
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.open();
|
||||
assert!(matches!(&browser.pending, Some(Request::Source(video)) if video.id == episode.id));
|
||||
browser.remove(true);
|
||||
assert_eq!(
|
||||
browser.removal.as_ref().unwrap().1.as_slice(),
|
||||
@@ -351,10 +302,8 @@ fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
|
||||
.state
|
||||
.contains(CollectionKind::WatchLater, &episode.id)
|
||||
);
|
||||
let (_, report) = browser
|
||||
.state
|
||||
.miruro_session(&episode, &source(&episode, "progress"))
|
||||
.unwrap();
|
||||
let report = browser.state.progress_file(&episode).unwrap();
|
||||
crate::video_state::register_identity(&report, &episode.id).unwrap();
|
||||
let mut output = fs::OpenOptions::new();
|
||||
output.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
@@ -391,89 +340,33 @@ fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_episode_and_offline_reopen_write_fresh_validated_source_sessions() {
|
||||
fn launcher_failure_does_not_mark_series_or_offline_episodes_as_started() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
let first = browser.screen().selected_video().unwrap();
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.select(Action::VideoNext);
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
first.clone(),
|
||||
Box::new(source(&first, "not-selected")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
let selected = browser.screen().selected_video().unwrap();
|
||||
assert_eq!(Identity::from_key(&selected.id).unwrap().episode, 7);
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.collection(CollectionKind::Saved);
|
||||
browser.open();
|
||||
browser.pending = None;
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(
|
||||
selected.clone(),
|
||||
Box::new(source(&first, "wrong-identity")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
browser.open();
|
||||
browser.pending = None;
|
||||
let mut invalid = source(&selected, "private-token");
|
||||
invalid
|
||||
.headers
|
||||
.insert("X-Invalid".into(), "private-header\nvalue".into());
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(selected.clone(), Box::new(invalid))),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
assert!(!browser.status.contains("private-token"));
|
||||
assert!(!browser.status.contains("private-header"));
|
||||
for token in ["fresh-one", "fresh-two"] {
|
||||
browser.open();
|
||||
assert!(
|
||||
matches!(&browser.pending, Some(Request::Source(video)) if video.id == selected.id && video.title == selected.title)
|
||||
);
|
||||
browser.pending = None; // Deliver the real source response without a network request.
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(
|
||||
selected.clone(),
|
||||
Box::new(source(&selected, token)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
let written = sources(&directory);
|
||||
assert_eq!(written.len(), 2);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.all(|source| source["identity"]["episode"] == 7
|
||||
&& source["identity"]["media_id"] == "first")
|
||||
);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-one"))
|
||||
);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-two"))
|
||||
);
|
||||
assert!(!browser.state.is_watched(&selected.id)); // Spawn failure/CLI completion is not progress.
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert!(!browser.state.is_watched(&selected.id));
|
||||
assert!(
|
||||
!browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &selected.id)
|
||||
);
|
||||
assert!(browser.screen().error.is_some()); // Native launch errors remain visible.
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.collection(CollectionKind::Saved);
|
||||
browser.open();
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert!(!browser.state.is_watched(&selected.id));
|
||||
assert!(
|
||||
!browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &selected.id)
|
||||
);
|
||||
assert!(browser.state.contains(CollectionKind::Saved, &selected.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -681,3 +574,197 @@ fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
|
||||
assert!(!browser.key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)));
|
||||
assert!(browser.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
browser.select(Action::VideoLast);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.select(Action::VideoFirst);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let ids = |browser: &Browser| {
|
||||
browser
|
||||
.screen()
|
||||
.playback_videos()
|
||||
.into_iter()
|
||||
.map(|video| video.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
assert!(browser.hints().contains("play checked"));
|
||||
assert!(browser.hints().contains("clear checks"));
|
||||
let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let text: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(text.contains("2 checked"));
|
||||
|
||||
if let Content::Series {
|
||||
data: Some(data), ..
|
||||
} = &mut browser.screen_mut().content
|
||||
{
|
||||
data.reverse();
|
||||
}
|
||||
browser.screen_mut().rebuild_display();
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
browser.history(false);
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
browser.history(true);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
browser.screen_mut().selection.select(None);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
browser.select(Action::VideoLast);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(ids(&browser), ["miruro:first:3"]);
|
||||
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.refresh();
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
// Checks made against old metadata while loading must not survive replacement.
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let replacement = series(browser.screen().selected_media().unwrap());
|
||||
browser.accept(browser.generation, Ok(Response::Series(replacement)));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(ids(&browser), ["miruro:first:3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_and_saved_show_rows_cannot_be_checked() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let generation = browser.generation;
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
|
||||
assert_eq!(browser.generation, generation);
|
||||
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
|
||||
assert!(browser.player.is_none());
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert!(browser.screen().playback_videos().is_empty());
|
||||
assert!(!browser.bindings().iter().any(|(action, _)| {
|
||||
matches!(
|
||||
action,
|
||||
Action::VideoToggle | Action::VideoClear | Action::VideoEnqueue
|
||||
)
|
||||
}));
|
||||
browser
|
||||
.state
|
||||
.save_show(&media("first"), &BTreeSet::new())
|
||||
.unwrap();
|
||||
browser.library(BTreeSet::new());
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let generation = browser.generation;
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SUPER));
|
||||
assert_eq!(browser.generation, generation);
|
||||
assert!(matches!(browser.screen().content, Content::Library { .. }));
|
||||
assert!(browser.player.is_none());
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert!(browser.screen().playback_videos().is_empty());
|
||||
enter_series(&mut browser);
|
||||
assert_eq!(browser.screen().len(), 2);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from(["miruro:first:3".into()])
|
||||
);
|
||||
browser.edit_search();
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(browser.input.as_deref(), Some(" "));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
browser.input = None;
|
||||
browser.history(false);
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from(["miruro:first:3".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
let first = browser.screen().selected_video().unwrap();
|
||||
browser.save(CollectionKind::WatchLater);
|
||||
browser.select(Action::VideoNext);
|
||||
let second = browser.screen().selected_video().unwrap();
|
||||
browser.save(CollectionKind::WatchLater);
|
||||
browser
|
||||
.state
|
||||
.set_watch_status(&first, WatchStatus::Watched)
|
||||
.unwrap();
|
||||
browser.collection(CollectionKind::WatchLater);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.select(Action::VideoNext);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().checked.len(), 2);
|
||||
browser.rebuild_collections();
|
||||
assert_eq!(browser.screen().checked.len(), 2);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from([second.id.clone()])
|
||||
);
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, second.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(browser.screen().selected_video().unwrap().id, first.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.remove(false);
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert!(browser.screen().playback_videos().is_empty());
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, second.id);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
|
||||
for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
let first = browser.screen().selected_video().unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.select(Action::VideoNext);
|
||||
let hovered = browser.screen().selected_video().unwrap();
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.set_watch_status(WatchStatus::Watching);
|
||||
assert!(!browser.state.contains(CollectionKind::Saved, &first.id));
|
||||
assert!(!browser.state.contains(CollectionKind::Watching, &first.id));
|
||||
assert!(browser.state.contains(CollectionKind::Saved, &hovered.id));
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &hovered.id)
|
||||
);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()]));
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, first.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.options.player = "/usr/bin/true".into();
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(browser.screen().error.is_none());
|
||||
assert!(browser.player.is_some());
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
|
||||
}
|
||||
}
|
||||
|
||||
+72
-56
@@ -278,17 +278,6 @@ fn miruro_library_does_not_overwrite_external_creations_or_edits() {
|
||||
assert_eq!(fs::read(&path).unwrap(), changed);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1092,17 +1081,16 @@ fn watching_and_session_snapshots_reject_oversized_files() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_in_state() {
|
||||
fn miruro_sessions_recover_collections_and_watching_without_source_files() {
|
||||
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());
|
||||
let report = state.progress_file(&video).unwrap();
|
||||
register_identity(&report, &video.id).unwrap();
|
||||
assert!(!report.with_file_name("source.json").exists());
|
||||
assert!(!report.exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(report.with_file_name("video-id")).unwrap(),
|
||||
@@ -1112,7 +1100,6 @@ fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_i
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
for path in [
|
||||
&source_file,
|
||||
&report.with_file_name("video-id"),
|
||||
&report.with_file_name("video.toml"),
|
||||
] {
|
||||
@@ -1172,18 +1159,6 @@ fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_i
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -1193,9 +1168,8 @@ fn miruro_v2_requires_matching_episode_eligible_known_duration_and_threshold() {
|
||||
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 report = state.progress_file(&video).unwrap();
|
||||
register_identity(&report, &video.id).unwrap();
|
||||
append(
|
||||
&report,
|
||||
miruro_record(&miruro_video(2), event, true, 100.0, Some(100.0)).as_bytes(),
|
||||
@@ -1232,9 +1206,8 @@ 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 report = state.progress_file(&video).unwrap();
|
||||
register_identity(&report, &video.id).unwrap();
|
||||
let valid = miruro_record(&video, "progress", true, 100.0, Some(100.0));
|
||||
for line in [
|
||||
valid.replace("\"version\":2", "\"version\":1"),
|
||||
@@ -1264,9 +1237,8 @@ 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 report = state.progress_file(&video).unwrap();
|
||||
register_identity(&report, &video.id).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());
|
||||
@@ -1274,9 +1246,8 @@ fn miruro_partial_reports_and_bad_siblings_recover_after_restart() {
|
||||
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();
|
||||
let bad = state.progress_file(&other).unwrap();
|
||||
register_identity(&bad, &other.id).unwrap();
|
||||
append(&bad, b"not json\n");
|
||||
append(&report, b"\n");
|
||||
assert!(state.poll_progress().is_err());
|
||||
@@ -1285,7 +1256,6 @@ fn miruro_partial_reports_and_bad_siblings_recover_after_restart() {
|
||||
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));
|
||||
@@ -1339,23 +1309,15 @@ fn miruro_state_rejects_catalog_rows_bad_titles_and_youtube_metadata() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miruro_source_sessions_validate_binding_size_and_maximum_identity_marker() {
|
||||
fn miruro_sessions_recover_the_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();
|
||||
let report = state.progress_file(&video).unwrap();
|
||||
register_identity(&report, &video.id).unwrap();
|
||||
append(
|
||||
&report,
|
||||
miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(),
|
||||
@@ -1375,9 +1337,8 @@ fn miruro_recovery_rejects_missing_snapshots_and_overlong_identity_markers() {
|
||||
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 report = state.progress_file(&video).unwrap();
|
||||
register_identity(&report, &video.id).unwrap();
|
||||
if missing_snapshot {
|
||||
fs::remove_file(report.with_file_name("video.toml")).unwrap();
|
||||
} else {
|
||||
@@ -1433,3 +1394,58 @@ fn youtube_v1_reports_keep_canonical_urls_and_unknown_field_tolerance() {
|
||||
assert!(state.poll_progress().unwrap());
|
||||
assert!(state.is_watched(&video.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_launch_rejects_invalid_and_duplicate_batch_identities_before_creating_sessions() {
|
||||
use crate::youtube::PlayerLaunch;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
let directory = TestDirectory::new();
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
for id in [
|
||||
"",
|
||||
"--help",
|
||||
"jNQXAC9IVRw&list=other",
|
||||
"日本語",
|
||||
"miruro:opaque_Show-1:0",
|
||||
"miruro:opaque_Show-1:01",
|
||||
"miruro:opaque_Show-1:2147483648",
|
||||
"miruro:../other:1",
|
||||
"miruro:opaque_Show-1:1&ep=2",
|
||||
"miruro-series:opaque_Show-1",
|
||||
] {
|
||||
let mut invalid_video = video();
|
||||
invalid_video.id = id.into();
|
||||
assert!(matches!(
|
||||
PlayerLaunch::start(&[video(), invalid_video], OsStr::new("terebi"), &mut state, false),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
for videos in [vec![], vec![video(), video()]] {
|
||||
assert!(matches!(
|
||||
PlayerLaunch::start(&videos, OsStr::new("terebi"), &mut state, false),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
assert!(!state.sessions.exists());
|
||||
assert!(state.streams.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_batch_prevalidates_snapshots_and_session_capacity() {
|
||||
let directory = TestDirectory::new();
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
let mut invalid_video = miruro_video(2);
|
||||
invalid_video.title = "invalid\nmetadata".into();
|
||||
assert!(state.progress_files(&[video(), invalid_video]).is_err());
|
||||
assert!(!state.sessions.exists());
|
||||
assert!(state.streams.is_empty());
|
||||
for _ in 0..MAX_STREAMS - 1 {
|
||||
state
|
||||
.streams
|
||||
.push(ProgressStream::new(directory.0.join("unused.jsonl")));
|
||||
}
|
||||
assert!(state.progress_files(&[video(), miruro_video(2)]).is_err());
|
||||
assert!(!state.sessions.exists());
|
||||
assert_eq!(state.streams.len(), MAX_STREAMS - 1);
|
||||
}
|
||||
|
||||
+21
-7
@@ -532,14 +532,28 @@ mod subprocesses {
|
||||
assert_reaped(pid);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_launch_rejects_unsafe_video_ids_before_spawning() {
|
||||
for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] {
|
||||
assert!(matches!(
|
||||
PlayerLaunch::start(id, OsStr::new("terebi"), Path::new("progress.jsonl")),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
fn video_urls_preserve_provider_identity_and_reject_invalid_keys() {
|
||||
assert_eq!(
|
||||
video_url("jNQXAC9IVRw").unwrap(),
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVRw"
|
||||
);
|
||||
assert_eq!(
|
||||
video_url("miruro:opaque_ID-7:3").unwrap(),
|
||||
"https://barelystarted.miruro.tv/watch/opaque_ID-7/episode?ep=3"
|
||||
);
|
||||
for id in [
|
||||
"",
|
||||
"--help",
|
||||
"jNQXAC9IVRw&list=other",
|
||||
"miruro:id:0",
|
||||
"miruro:id:03",
|
||||
"miruro:../id:3",
|
||||
] {
|
||||
assert_eq!(
|
||||
video_url(id).unwrap_err().kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user