From f21a1fbe63764c27929e0af0844207d634a82647 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 09:15:09 +0200 Subject: [PATCH] research: investigate Miruro segment metadata handoff --- docs/miruro-segments.md | 133 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/miruro-segments.md diff --git a/docs/miruro-segments.md b/docs/miruro-segments.md new file mode 100644 index 0000000..13d9075 --- /dev/null +++ b/docs/miruro-segments.md @@ -0,0 +1,133 @@ +# 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:** 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. + +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: + +```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. +- **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. +- **Recommendation:** address every launch path, including saved/watch-later/currently-watching reopening, rather than enriching only a currently open catalog episode. + +## Tubular → Terebi boundary + +### Where Tubular loses the metadata + +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. + +**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`. + +### 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. + +**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. + +**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. + + +## 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 -o `, 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