refactor: delegate Miruro playback resolution to Terebi

This commit is contained in:
2026-10-22 09:45:03 +02:00
parent f21a1fbe63
commit 3fda432dbd
15 changed files with 248 additions and 825 deletions
+10 -10
View File
@@ -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. Both providers use `--new-instance --url … --progress-file PATH`; Miruro requires native Miruro 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.
@@ -61,13 +61,13 @@ Each selection opens an independent player instance without replacing existing p
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 |
| `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 |
| Enter | Open a series, or pass the selected episode URL to Terebi for resolution and playback |
| `-` / `+` | 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 +85,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
@@ -284,11 +284,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-launch progress streams, expected identities, metadata snapshots, and durable admission/manual-edit markers.
State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB.
Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. 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
@@ -376,7 +376,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 +386,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
View File
@@ -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`.
+9 -20
View File
@@ -2,13 +2,13 @@
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:** Miruro supplies episode-relative opening/ending intervals, but **Terebi cannot consume them today**. Tubular retains them while browsing, then drops them before the source-file handoff; Terebi silently ignores an added `segments` property. Both sides need an explicit contract change. Availability is sparse, and timing must be checked against the selected stream's actual duration; alignment across different cuts remains unproven.
**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 — native Tubular probe:** the actual `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**. Safe output projection:
**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
@@ -74,23 +74,18 @@ Useful anchors in the minified [bundle][client]: `Cn=.05`, `function wn`, `provi
- **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.
- **Recommendation:** use the existing Terebi source-session handoff if its contract can carry the metadata, validating finite/nonnegative/ordered intervals and actual media-duration bounds at the playback boundary. Preserve optional provenance/reference-length fields if they become available; do not claim the sample has them or that the deployed frontend's optional checks guarantee cross-provider synchronization. No new endpoint, media download, clock-based heuristic, or separate segment lookup service is justified by this evidence.
- **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 boundary
## Tubular → Terebi seam
### Where Tubular loses the metadata
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.
1. [`Client::detail`](../src/miruro.rs#L321) decodes raw episode records, including `skip_times`. [`series`](../src/miruro_browser.rs#L238) retains them in `Episode.metadata`; no additional upstream request is needed while browsing an already-loaded series.
2. [`Episode::snapshot`](../src/miruro_browser.rs#L29) projects the selected episode into a generic `Video`, without segment metadata. [`Screen::selected_video`](../src/miruro_ui.rs#L145) uses that snapshot for series playback; offline collections already contain only `Video` snapshots.
3. [`open`](../src/miruro_ui.rs#L522) submits `Request::Source(Video)`. The [worker](../src/miruro_browser.rs#L87) calls [`Client::source_document`](../src/miruro.rs#L362), which fetches only `/api/sources`. It cannot reconstruct the missing intervals from that response.
4. [`SourceDocument`](../src/miruro.rs#L90) has no segment field. [`State::miruro_session`](../src/video_state.rs#L379) serializes it into the existing private `source.json`; that transport can be reused once both contracts change. No new CLI argument or separate segment file is needed.
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.
**Recommendation:** preserve episode-bound metadata through the shared source-resolution path, not a series-screen-only patch. Offline episode snapshots lack both skip metadata and a canonical route slug; adoption must retain enough metadata/route information to refresh them or deliberately store the episode metadata for those launches. Do not guess a slug from the combined episode display title or confuse `episode_number` with `absolute_number`.
### Historical source-file decoder evidence (superseded transport)
### What Terebi needs
The current [source-v1 contract](../../terebi/docs/miruro-playback-handoff.md#source-document-version-1) contains no semantic intervals and explicitly lists intro/outro controls as out of scope. [`ResolvedSource.Document`](../../terebi/Sources/App/ResolvedSource.swift#L38) decodes only version, URL, headers, identity, title and subtitles. [`validated`](../../terebi/Sources/App/ResolvedSource.swift#L84) requires version 1 and constructs a runtime source without segments.
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.
@@ -102,13 +97,7 @@ 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.
**Minimum implementation sequence — recommendation, not implemented:**
1. Agree and implement a new source-document version in Terebi with optional episode-relative intervals: kind and fractional-second start/end. Define missing/empty metadata, allowed kinds, limits and invalid-interval handling. A version gate prevents old players from silently claiming support; keep the existing private file and identity contract.
2. Retain intervals in Terebi's resolved runtime source and playback lifecycle; clear them on media replacement and check them against actual duration. For skip controls, reuse existing acknowledged seeking rather than creating another playback clock. [`finishSeeking` and `seekRelative`](../../terebi/Sources/App/TerebiApp.swift#L690) already route seeks through native/Cast behavior; there is no semantic intro/outro action today. Automatic skipping is a separate behavior decision, not required to transport metadata.
3. Update Tubular's projection/validation and every launch path to emit the agreed contract. Preserve unknown metadata without turning it into a skip action; empty intervals must not block ordinary playback. Add consumer-visible checks for wrong-episode metadata, invalid bounds and media replacement when implementing.
Terebi's own [episode-URL resolver](../../terebi/Sources/App/StreamResolver.swift#L103) also uses only `/api/sources` and would need metadata acquisition separately if that entry point is included in a future feature. External source-file support alone would not make native URL resolution segment-aware.
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
+73
View File
@@ -0,0 +1,73 @@
# 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 `State::progress_file(video)` and `PlayerLaunch::start(&video.id, player, progress_file)` for both providers. 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.
+7 -129
View File
@@ -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"))
}
+2 -2
View File
File diff suppressed because one or more lines are too long
+13 -249
View File
@@ -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
+1 -8
View File
@@ -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;
+16 -47
View File
@@ -265,7 +265,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();
}
@@ -317,7 +317,6 @@ struct Browser {
enum Intent {
Catalog,
Series(String),
Source(String),
}
enum LibraryModal {
@@ -526,12 +525,20 @@ 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() {
return;
self.invalidate();
self.screen_mut().error = None;
let result = self.state.progress_file(&video).and_then(|progress_file| {
// Dropping the launcher cancels only its CLI child, not the native player.
self.player = None;
PlayerLaunch::start(&video.id, &self.options.player, &progress_file)
});
match result {
Ok(player) => {
self.player = Some(player);
self.status = format!("Opening {} in Terebi…", safe(&video.title));
}
Err(error) => self.fail(error),
}
let id = video.id.clone();
self.request(Request::Source(video), Intent::Source(id));
self.status = "Resolving fresh direct Sun source…".into();
}
}
fn fail(&mut self, error: impl std::fmt::Display) {
@@ -595,34 +602,6 @@ impl Browser {
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 +889,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 {
@@ -1064,10 +1037,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);
@@ -1292,7 +1261,7 @@ impl Browser {
) {
"Open series"
} else {
"Play episode (fresh source)"
"Play episode in Terebi"
},
),
(Action::VideoNext, "Next title/episode"),
@@ -1804,7 +1773,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(),
+1 -22
View File
@@ -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;
@@ -376,26 +375,6 @@ impl State {
Ok(path)
}
pub fn miruro_session(
&mut self,
video: &Video,
source: &SourceDocument,
) -> io::Result<(PathBuf, PathBuf)> {
source.validate()?;
if Identity::from_key(&video.id)? != source.identity {
return Err(invalid("Source identity must match the playback episode"));
}
let contents = serde_json::to_vec(source).map_err(|e| invalid(e.to_string()))?;
if contents.len() > MAX_SOURCE_BYTES {
return Err(invalid("Playback source exceeds the 1 MiB limit"));
}
let progress_file = self.progress_file(video)?;
let source_file = progress_file.with_file_name("source.json");
atomic_write(&source_file, None, &contents)?;
register_identity(&progress_file, &video.id)?;
Ok((source_file, progress_file))
}
pub fn poll_progress(&mut self) -> io::Result<bool> {
let mut error = self.discover().err();
let count = self.streams.len().min(16);
+8 -58
View File
@@ -915,67 +915,17 @@ 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",
));
}
let url = if valid_video_id(id) {
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))?
};
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,
player: &OsStr,
progress_file: &Path,
) -> io::Result<Self> {
if !std::fs::symlink_metadata(source_file)?.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Source document must be a regular file",
));
}
let source_file = std::fs::canonicalize(source_file)?;
let parent = std::fs::canonicalize(progress_file.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "Missing progress directory")
})?)?;
if source_file.parent() != Some(parent.as_path())
|| progress_file
.file_name()
.is_none_or(|name| name != "progress.jsonl")
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Source and progress must share their private session directory",
));
}
let progress_file = parent.join("progress.jsonl");
match std::fs::symlink_metadata(&progress_file) {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
Ok(_) => {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"Progress destination must not exist",
));
}
}
let mut command = isolated_command(player);
command
.arg("--new-instance")
.arg("--source-file")
.arg(source_file)
.args(["--new-instance", "--url", &url])
.arg("--progress-file")
.arg(progress_file);
Self::launch(command)
+33 -56
View File
@@ -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]
+21 -128
View File
@@ -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]
+17 -56
View File
@@ -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 {
+13 -2
View File
@@ -534,8 +534,19 @@ mod subprocesses {
}
#[test]
fn player_launch_rejects_unsafe_video_ids_before_spawning() {
for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] {
fn player_launch_rejects_invalid_provider_ids_before_registration_or_spawning() {
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",
] {
assert!(matches!(
PlayerLaunch::start(id, OsStr::new("terebi"), Path::new("progress.jsonl")),
Err(error) if error.kind() == io::ErrorKind::InvalidInput