Files
tubular/docs/miruro-api.md
T

471 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Miruro API research
Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**. These are reverse-engineering notes, not an official API specification. Tubular now has a native API module and runnable smoke example, but no Miruro TUI or Terebi integration.
Evidence labels:
- **Verified:** an actual HTTP response obtained through the research JavaScript `fetch` transport, or the explicitly identified local Rust probe below, with bounded timeouts and no supplied authentication, cookies, or protection bypass.
- **Frontend:** behavior read from deployed first-party JavaScript; not necessarily a server-side guarantee.
- **Unknown:** not established by those sources or requests.
**Transport distinction:** research requests returned JSON, while a local `curl` source-validation request returned **403 `text/html`**, a Cloudflare block page. A subsequent local Rust `ureq` probe passed the search → detail/episodes → sources → HLS master/subtitle path without bypass machinery. Native access is proven for that client and sample, not every transport, network, provider or future request. Inspect HTTP status and Content-Type before decoding; stop on protection pages rather than treating them as empty results.
## Endpoint map
All paths below are relative to the scoped base URL. Each row was live-verified unless explicitly labeled Frontend. Route-data responses require the [SvelteKit decoder](#sveltekit-route-data), even when Content-Type is `application/json`.
| Purpose | GET path and parameters | Response |
| --- | --- | --- |
| Autocomplete | `/api/search/browse?q=<query>&limit=8&type=ANIME` | Plain JSON `{data, next_cursor, has_more}` |
| Full search / browse | `/search/__data.json?q=<query>&cursor=<returned cursor>` | Route data: `{items, entityItems, nextCursor, hasMore, filters, …}` |
| Detail / episode metadata | `/info/<id>/<slug>/__data.json` | Streamed route data: `detail`, promised `episodes` and related metadata |
| Watch metadata + initial sources | `/watch/<id>/<slug>/__data.json?ep=<number>` | Streamed route data: `media`, promised `episodesData` and `sourcesData` |
| Fresh episode sources | `/api/sources?id=<id>&n=<number>` | Plain JSON `{episodeNumber, tracks, config, providerOrder}` |
| Home | `/__data.json` | Streamed route data: `hero`, promised `rails` and `sideLists` |
| Trending | `/trending/__data.json` | Route data: `{items, requestedPage, year, season, years, seasons}` |
| Schedule page | `/schedule/__data.json` | Route data: `{entries, weekStart, weekOffset, now, track}` |
| Schedule events | `/api/schedule?startAt=<Unix seconds>&track=all` | Plain JSON event array |
| Genre/tag references | `/api/discover/refs` | Plain JSON `{genres, tags}` |
| Discover page | `/discover/__data.json` | Route data: `{lens, lensOffset, spotlights, picks, lensCount}` |
| Personalized discover feed | `/discover/feed` | Plain JSON `{spotlights, picks}`; empty arrays verified without seeds |
Sources: [route map][routes], [search/layout][layout], [detail][detail-client], [watch][watch-client], [home][home-client], [trending][trending-client], [schedule][schedule-client], [discover][discover-client], and the live URLs in the verification ledger. No plain JSON detail/episodes endpoint was identified in those bundles; none was guessed or probed. This is not proof that none exists. Other Miruro domains and third-party `/api/secure/pipe` wrappers are outside this contract.
## IDs and the search-to-stream flow
Use Miruro's **opaque catalog ID**, not an external numeric ID:
```text
Miruro id: OERe_a0YS4h-DlsBDOeXwX7HOO6316tw
AniList: 195516
MAL: 61987
Slug: the-apothecary-diaries-season-3
```
Verified search records expose `external_ids` as a map of **string arrays**, for example `{"anilist":["195516"],"mal":["61987"]}`. Unmapped provider keys may be absent. Watch data separately exposes numeric `anilistId`; `media.id` remains the opaque Miruro ID. Numeric-ID reverse lookup and route aliases are unknown.
The minimum evidenced flow is:
1. Search and retain the opaque `items[].id` / `data[].id`, title, and artwork.
2. Fetch detail route data and resolve its `episodes` chunk, or fetch watch route data and resolve `episodesData`.
3. Select an actual returned episode number. Detail `episode_number:1` mapped to `absolute_number:49`; source query uses **1**, not 49 or an array index.
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.
## Search and discovery contracts
### Plain autocomplete
**Verified:** [this request][browse-live] returned eight summary records, `next_cursor` and `has_more:true`. **Frontend:** layout trims `q`, sends literal `limit=8&type=ANIME`, debounces input 300 ms and aborts older requests. Empty input clears local suggestions without a request.
An abbreviated observed result, with additional fields omitted:
```json
{
"data": [{
"id": "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw",
"external_ids": {"anilist": ["195516"], "mal": ["61987"]},
"title": {
"romaji": "Kusuriya no Hitorigoto 3rd Season",
"english": "The Apothecary Diaries Season 3",
"native": "薬屋のひとりごと 第3期"
},
"format": "TV",
"status": "RELEASING",
"episode_count": 12,
"episode_counts": {"raw": 2, "sub": 2, "dub": null},
"episode_duration_minutes": 24,
"season": "FALL",
"season_year": 2026,
"average_score": 85
}],
"next_cursor": "<redacted returned cursor>",
"has_more": true
}
```
Summary fields also observed: `description`, `cover_url`, `cover_color`, `banner_url`, `background_url`, `logo_url`, `source`, `country_of_origin`, `popularity`, `is_adult`, `next_airing:{episode_number,air_at}`, `show_cluster_id`, `show_cluster_position`. Across records, English title, counts, duration, season/year, score, colors/artwork, next airing, and cluster fields could be null. Null is not zero or an empty string.
**Pagination trap:** sending this response's `next_cursor` as `cursor` on the same otherwise-valid autocomplete request returned **400**, `detail:"Unsupported browse request."`. `/api/search/browse?limit=2&type=ANIME` also returned 400, but that probe changed both limit and omission of `q`, so it does not isolate the cause. Do not promise plain-API pagination or infer supported limits from these probes.
### Full search, filters, and pagination
**Verified:** decoded `/search/__data.json?q=apothecary` yielded:
```text
{
mode: "anime", items: MediaSummary[], entityItems: [],
nextCursor: null, hasMore: false, limit: 24, cursor: "",
entityPage: 1, filters: { ...string-valued fields... },
context: null, error: null
}
```
It returned eleven summaries. An unmatched query returned `items:[]`, `entityItems:[]`, `nextCursor:null`, `hasMore:false`, `error:null`, HTTP 200. Empty results are distinct from errors.
Unfiltered `/search/__data.json` returned 24 summaries and a non-null `nextCursor`; a subsequent request with that token as `cursor` returned another 24 summaries with a different first ID, echoed `cursor`, and `hasMore:true`. Retain the returned token opaquely and URL encode it. Token lifetime, binding to filters/cookies and invalid-token handling are unknown. Do not reuse the autocomplete cursor.
**Frontend:** [search page][search-client] and route dependency metadata expose these page query keys:
| Keys | Representation / behavior |
| --- | --- |
| `mode` | `anime`, `studio`, `staff`, `character` |
| `q` | Trimmed query; entity input has a client-only 200-character limit |
| `cursor` | Anime pagination; reset when filters change |
| `page` | Numeric entity pagination, not anime pagination |
| `studios`, `staff`, `characters` | Numeric entity-ID context filters |
| `genres`, `format`, `status`, `tracks`, `season`, `source`, `country` | Single or comma-separated selected values; genre names, not reference IDs |
| `adult` | Empty/unset is labeled Non-adult; `true` is labeled Adult |
| `yearFrom`, `yearTo`, `minScore`, `maxScore` | String-valued range bounds; score slider 0–100 |
| `sort` | One frontend option string |
Frontend option values ([source][filter-options]), **not guaranteed server enums**:
- `format`: `TV`, `TV_SHORT`, `MOVIE`, `OVA`, `ONA`, `SPECIAL`, `MUSIC`.
- `status`: `RELEASING`, `FINISHED`, `NOT_YET_RELEASED`, `HIATUS`, `CANCELLED`.
- `tracks`: `raw` (**Sub / Raw**), `dub` (**Dub**), `sub` (**Scheduled Subs**). These discovery labels are not the playback API's `ssub`/`sub` classification.
- `season`: `WINTER`, `SPRING`, `SUMMER`, `FALL`; `country`: `JP`, `KR`, `CN`, `TW`.
- `source`: `ORIGINAL`, `MANGA`, `LIGHT_NOVEL`, `WEB_NOVEL`, `NOVEL`, `VISUAL_NOVEL`, `VIDEO_GAME`, `GAME`, `DOUJINSHI`, `ANIME`, `COMIC`, `LIVE_ACTION`, `MULTIMEDIA_PROJECT`, `PICTURE_BOOK`, `OTHER`.
- `sort`: empty (Most Popular), `-score`, `-season_year`, `season_year`, `title`, `-title`, `-episode_count`, `episode_count`, `-duration`, `duration`, `-started_on`, `-ended_on`, `-updated_at`.
The grid stores `hestia-page-size` in a cookie, computing 24 or 12/15/18/21 by layout. No full-search query `limit` was observed; 24 is a sampled response, not a guaranteed default. Entity responses/filter semantics, cookie acceptance/ranges and server validation were not exercised.
### Other discovery surfaces
**Verified**, with frontend parameter behavior noted separately:
- **Home:** decoded `{hero, now, rails:Promise, sideLists:Promise}`. Hero records are camelCase (`externalIds`, `coverUrl`, `backdropUrl`, `episodeCounts`, etc.), unlike search summaries. `rails` resolves `raw`/`dub` latest entries with `animeId`, `episode`, `track`, Unix-second `airingAt`; `sideLists` has `airing`/`upcoming` summary arrays. Use `animeId`, not an event ID, for sources.
- **Trending:** `{items, requestedPage, year, season, years, seasons}`. **Frontend** `year`/`season` change the loaded collection; `page` slices it locally into 18-item pages. It is not evidence of server-side cursor pagination.
- **Schedule page:** `{entries, weekStart, weekOffset, now, track}`; dependency metadata reads `week` and `track`. Plain schedule returns `[{id,airingAt,episode,track,media}]`; `startAt` and `airingAt` are Unix **seconds**. Use `media.id`, not event `id`, for sources. **Frontend** fetches `track=all`, shifts weeks by `7*86400`, and filters all/sub/dub locally. API sub/dub variants are untested; returned entries included `track:"raw"`.
- **References:** `/api/discover/refs` returns `genres:[{id,name}]` and `tags:[{id,name,category,parent_id,parent_name}]`; parent fields may be null. Search uses genre names. A tag filter query was not observed in full-search controls.
- **Discover page:** `{lens:{id,label,tagline,recipe}, lensOffset, spotlights, picks, lensCount}`. Spotlights are camelCase; picks use snake_case summaries. Dependency metadata reads `season`, `year`, `cursor`, `d`; **Frontend** refresh sets a random `d`. These are not a proven paginated API.
- **Personalized feed:** unseeded `/discover/feed` returned empty arrays. **Frontend** sends `seeds=<comma-separated numeric list IDs>&exclude=<comma-separated IDs>&provider=<list provider>`, using up to eight seeds and fallback provider `anilist`. These IDs belong to the external list service, not Miruro's opaque catalog. The UI gates personalization on sign-in; seeded-request authentication and schema are unknown.
## Detail, episodes, and watch data
**Verified:** detail route initially decodes to:
```text
{
detail: MediaDetail, id, currentSlug, tvdbBackgrounds,
characters: Promise, relations: Promise, recommendations: Promise,
artworks: Promise, show: Promise, episodes: Promise
}
```
`detail` extends summary metadata with `alternate_titles:[{value,kind,language}]`, `started_on`, `ended_on`, `broadcast`, `release_schedules`, `mal_score`, `mal_members`, `aired_season`, `trailer_youtube_id`, `genres`, `tags:[{name,rank,category,is_spoiler}]`, `studios:[{id,name,is_main,is_animation_studio}]`, `franchise`, `updated_at`. Dates and some nested identifiers/counts can be null. Broadcast/release schedules describe airing metadata, not stream provider URLs or playback availability.
The sample title's resolved `episodes` contained two records despite a planned `episode_count:12`. Abbreviated episode with all observed keys:
```json
{
"episode_number": 1, "absolute_number": 49, "aired_season_number": 3,
"kind": "regular", "canon_type": null, "title": "Locusts",
"synopsis": "A locust plague is looming, and mysteriously, a book on insects belonging to the Shi clan appears.",
"thumbnail_url": "https://image.tmdb.org/t/p/original/v2k4Nj11mnVwyUhqktOmYr3E4yV.jpg",
"aired_on": "2026-10-02", "duration_seconds": 1500, "finale_type": null,
"skip_times": [
{"kind":"op","start_seconds":63.313,"end_seconds":153.237},
{"kind":"ed","start_seconds":1335.252,"end_seconds":1425.384}
]
}
```
Do not synthesize playable episodes from planned counts. These records do not carry per-track availability flags; request actual sources for the selected episode. Null dub count does not conclusively prove no playable dub.
Watch route initially has `{media, episodesData:Promise, sourcesData:Promise, anilistId, externalIds, episodeNumber, tvdbBackgrounds}`. `media` is adapted camelCase: `episodeCounts`, `duration` (minutes), `seasonYear`, `startDate`, `coverImage`, `nextAiringEpisode`, etc.; it is not interchangeable with `detail`.
Resolved watch `episodesData` has:
```text
{
episodes: [{id:"1",number:1,title,image,airDate,duration:1500,description,filler:false}, ...],
currentEpisode: <episode 1>, prevEpisode: null, nextEpisode: <episode 2>,
aniskip: [
{episode:1,type:"op",start:63.313,end:153.237},
{episode:1,type:"ed",start:1335.252,end:1425.384}
]
}
```
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
### Plain source response
**Verified:** [fresh sources][sources-live] returned the following observed structure. Fields seen only as null have unknown non-null types; this is not an exhaustive guaranteed schema.
```text
Sources { episodeNumber:number, tracks:Track[], config:{provider:ProviderConfig}, providerOrder:string[] }
Track { track:string, providers:Provider[] }
Provider { provider:string, servers:Server[], subtitles:Subtitle[], thumbnails:null|{vtt:string,sprite:null}, downloads:Download[] }
Server { server:string, headers:null|{header:string}, embed:null|{url:string}, streams:Stream[] }
Stream { url:string, format:string, quality:null, resolution:null, codec:null, embed:null }
Subtitle { language:string, label:string, file:string, format:string, default:boolean }
Download { url:string, quality:null, size:null, fansub:null }
```
- Sample tracks were `ssub` (soft subtitles) and `sub` (hard-subtitled, UI `h-sub`); **Frontend** also supports `dub`. No separate `audio` property was observed. Discovery `raw/sub/dub` counts are a different vocabulary.
- Sample direct streams used `format:"hls"`. **Frontend** recognizes HLS strings/MIME types, DASH/MPD, MP4, WebM and Ogg, and defaults a missing format to HLS. Those are consumer inputs, not verified server formats.
- Quality/resolution/codec were null, yet a fetched master advertised `RESOLUTION=1920x1080`; do not equate null metadata with absent HLS renditions.
- An iframe-only server (`bun`) had `streams:[]`, `headers:null`, `embed:{url}`. An embed page is not a native stream.
- Subtitle language strings were provider-specific (`en`/`eng`, `sp`, `po`); distinct Spanish labels shared `language:"sp"`. Preserve labels, not only language codes. VTT was verified; SRT support is frontend-only.
- Subtitle/thumbnail URLs can end in `.jpeg` while returning `text/vtt`. Classify by format/content, not extension. Relative thumbnail sprite references must be resolved against the VTT URL.
- Download URLs can be download pages; their being raw media files is unproven.
- Source URLs may contain tokens. Fetch fresh before playing; do not persist tokens as catalog identity or log full URLs. Expiry and IP binding are unknown.
A sanitized projection (URL values deliberately redacted):
```json
{
"episodeNumber": 1,
"tracks": [{"track":"ssub","providers":[{
"provider":"sun",
"servers":[{
"server":"icarus-2-1",
"headers":{"Origin":"https://ultracloud.cc","Referer":"https://ultracloud.cc/"},
"embed":null,
"streams":[{"url":"<fresh HLS URL>","format":"hls","quality":null,"resolution":null,"codec":null,"embed":null}]
}],
"subtitles":[{"language":"en","label":"English","file":"<fresh VTT URL>","format":"vtt","default":true}],
"thumbnails":{"vtt":"<fresh thumbnail VTT URL>","sprite":null}
}]}]
}
```
This projects one server/provider and omits config, other tracks/providers and downloads; it is not a complete response. Preserve the actual returned header strings without normalization.
### Runtime config and selection
**Verified:** response `config` entries contain `capabilities:{sub,ssub,download,skip_times,thumbnails}`, `visible`, `player` (`native`/`iframe` observed), `parent`, `proxy`, `variantOrder`, `cors`, `fallback`, and optional `hls` / `downloadWorker` fields. `proxy` was false or `{rotate:boolean,segments:boolean}`; `hls` contained `segments:{enabled,origin}` and `query:{enabled,params}`. Non-null fallback shape is unknown.
Observed `providerOrder`: `sun, bee, pewe, hop, ally, kiwi, moo, bonk, knob, nun, bun, cog, twin, telli`. Config includes providers absent from the selected episode. Treat names/order as runtime data. Capability flags and `cors` were false even where streams/subtitles/downloads/thumbnails were returned; they are not verified availability gates.
**Frontend** ([watch client][watch-client]):
1. Use saved sub/dub and provider preferences. For sub, consult each provider's `variantOrder` for `sub`/`ssub`, then append missing variants; iterate `providerOrder` and retain only actual provider/track combinations.
2. Prefer exact `provider:track`, then same provider, then first available. If a requested language has no entries, try the opposite language. This is existing UI behavior, not a recommendation to silently change a native user's language.
3. Flatten selected provider servers' nonempty streams in returned order; start at the first and advance on failure. Retain iframe embeds only for `player:"iframe"`.
4. Browser stream projection extracts Referer case-insensitively, but raw API also returns Origin. A native wrapper should retain the entire header map.
### Direct resources, proxies, and browser player
**Verified:** using fresh Sun stream/subtitle/thumbnail URLs and the server's Origin/Referer headers, research GETs obtained an HLS master (200 `application/vnd.apple.mpegurl`) and two VTT bodies (200 `text/vtt`). No nested rendition playlist, media segment, key, sprite, download page or player was exercised. Whether either header is mandatory was not tested. **This proves resource retrieval, not full playback.**
**Frontend:** deployment HTML [environment][homepage] identifies `https://strm.cx`, proxy bases `https://s1.keeply.top/` and `https://s2.keeply.top/`, and a public proxy obfuscation key. Watch source shows:
- Sun uses direct streams (`proxy:false` in the sample). Bee/Hop use `{rotate:false,segments:true}`; a bare direct URL is not proven equivalent for these providers.
- Browser proxy paths encode URL/Referer and use `/pl.m3u8`, `/sub.vtt`, `/seg.jpg`. This is a private deployment convention, not `/api/secure/pipe`. No replay of that obfuscation protocol was needed for direct-resource verification.
- Provider `hls` overrides can rewrite segment/init-resource hosts and append query parameters to playlists, including nested playlists. Browser code resolves relative URLs/`URI="…"` attributes, rewrites playlists into `blob:` URLs, drops failed variants, and bounds recursion, size and time. Those blob URLs are browser-local, not reusable native source URLs.
- 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.
For a future native player: preserve fresh query tokens, per-server headers and relevant provider config; correctly resolve nested/relative HLS URLs. Do not apply one provider's headers indiscriminately to another provider's subtitles/thumbnails. Terebi currently receives only `--new-instance --url … --progress-file PATH` in Tubular ([existing usage](../README.md#usage)); custom headers, external subtitles, HLS overrides, native seeking and full Miruro playback remain unverified.
## SvelteKit route data
Sources: [deployed fetch/devalue decoder][transport-client], [route map][routes], and live detail/watch/home responses.
Append `/__data.json` to the page path and retain query parameters. The browser transport can add `x-sveltekit-invalidated=<bit string>` and `x-sveltekit-trailing-slash=1`; neither was necessary in the research requests.
An **illustrative**, minimal envelope:
```json
{"type":"data","nodes":[{"type":"data","data":[{"someKey":1},"someValue"],"uses":{}},null]}
```
Actual responses had layout at node 0, route at node 1. Each node's `data` is its own **devalue flattened table**, with root at index 0. Object-property and ordinary-array numeric values are references into that table, not literal payload numbers. Null is a table value; undefined uses sentinel `-1`. Decoder also handles array holes (`-2`), NaN/infinities/negative zero (`-3`…`-6`), sparse arrays (`-7`) and tagged Date/Set/Map/BigInt/other types. A generic recursive integer dereferencer is not a complete decoder. Keep layout configuration separate from route models.
Promised fields are tagged `['Promise', <table index referencing promise ID>]`. Streamed `text/sveltekit-data` is **newline-delimited JSON**, not one JSON object:
```text
{"type":"data","nodes":[...]}
{"type":"chunk","id":<promise ID>,"data":<independent devalue table>}
```
Resolve by decoded promise ID; each chunk has a new table. Arrival order is not field order: sample detail chunks arrived 2,1,6,3,4,5. IDs are response-local, not constants. Consume the full body or track promises incrementally. Frontend also decodes `error` chunks to reject a promise; that error path was not live-exercised. Top-level redirect envelopes require separate handling.
**Verified:** `/info/invalid/invalid/__data.json` returned HTTP **200** with `{"type":"redirect","location":"/"}`. Status 200 therefore does not prove a successful detail response. Empty search arrays, redirect envelopes, API problem responses and transport failures must remain distinct.
## Errors and caching
**Verified API problem shape:**
```json
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Unsupported request parameters."}
```
`Content-Type: application/problem+json`, `Cache-Control: no-store`.
- `/api/sources` without parameters: 400, `detail:"Anime and episode are required."`.
- Known ID with `n=0` or `n=not-a-number`: 400, `detail:"Unsupported request parameters."`.
- `id=invalid&n=1`: same 400/detail; this does not establish behavior for a syntactically valid nonexistent ID.
- Unsupported tested browse requests: 400, `detail:"Unsupported browse request."`.
- Local curl of known ID with `n=0`: Cloudflare **403 HTML**, before the JSON validation behavior seen in research transport. The cause/client fingerprint/IP distinction is unknown.
Source success and source errors had no `Access-Control-Allow-Origin` header. The direct Sun master/subtitle responses had ACAO `https://ultracloud.cc`. Neither observation guarantees general browser cross-origin access. Server rate limits, stable error taxonomy, authentication guarantees, token expiry and protection behavior are unknown; no rate/protection stress tests occurred.
Observed Cache-Control values:
| Surface | Cache-Control |
| --- | --- |
| Plain browse success | `public, max-age=300, s-maxage=900, stale-while-revalidate=3600, stale-if-error=86400` |
| Plain schedule | `public, max-age=0, s-maxage=60, stale-while-revalidate=60` |
| Discover references | `public, max-age=0, s-maxage=300, stale-while-revalidate=60` |
| Source success/errors and normal route data | `no-store` |
| Missing-detail redirect; discover feed | `private, no-store` |
Respect current response headers; these are sampled deployment values, not constants. Metadata cacheability does not make stream URLs durable.
## Verification ledger
All research data GETs below used `fetch` with 20–25-second timeouts, no supplied auth/cookie/Origin/Referer unless stated. JS bundles used 15–20-second bounds. These are research-transport results, **not successful local curl/native playback claims**. Stream tokens and pagination cursors are intentionally not recorded.
| Request | Status / Content-Type | Observed result |
| --- | --- | --- |
| [Autocomplete][browse-live] | 200 / application/json | Eight summaries; next_cursor; has_more |
| Browse without q, changed limit 2 | 400 / application/problem+json | Unsupported browse request |
| Valid browse + own returned cursor as `cursor` | 400 / application/problem+json | Unsupported browse request |
| [Full query search][search-live] | 200 / application/json | Eleven summaries; no nextCursor |
| [Full browse][search-all-live] + its nextCursor request | Both 200 / application/json | Two 24-item pages; different first IDs |
| [Unmatched search][empty-search-live] | 200 / application/json | Empty items; error:null |
| [Detail][detail-live] | 200 / text/sveltekit-data | Initial detail + six promised chunks; two episodes |
| [Invalid detail][missing-detail-live] | 200 / application/json | Redirect envelope to `/` |
| [Watch ep=1][watch-live] | 200 / text/sveltekit-data | Initial media + episodesData/sourcesData chunks |
| [Source ep=1][sources-live] | 200 / application/json | ssub/sub, direct + iframe providers, runtime config |
| Source missing parameters / n=0 / nonnumeric n / invalid ID | Each 400 / application/problem+json | Validation details above |
| [Home][home-live] | 200 / text/sveltekit-data | Hero + rail/side-list chunks |
| [Trending][trending-live] | 200 / application/json | 97 sampled items; year/season choices |
| [Schedule page][schedule-live] / [plain schedule][events-live] | Both 200 / application/json | 240 sampled entries |
| [References][refs-live] | 200 / application/json | 19 genres, 1,742 tags in sample |
| [Discover][discover-live] | 200 / application/json | Lens, 12 spotlights, 12 picks |
| [Unseeded feed][feed-live] | 200 / application/json | Empty spotlights/picks |
| Fresh ssub/Sun first-server master, with server Origin/Referer | 200 / application/vnd.apple.mpegurl | `#EXTM3U`; one 1920x1080 variant; no redirect |
| Same provider default subtitle / thumbnail VTT, same headers | Both 200 / text/vtt | WEBVTT cues; thumbnail sprite coordinates; no redirect |
| [strm client][strm-client] | 200 / text/javascript | Source inspected, not executed |
| Local shell curl, known source ID with n=0 | 403 / text/html | Cloudflare block page |
One worker's homepage fetch without a trailing slash returned nginx 404, while the trailing-slash HTML read succeeded. This unresolved transport/URL discrepancy is not evidence of an API rule. The initial documentation research did not run application tests; the later native-wrapper verification is recorded below. Neither investigation used a browser, playback, media-segment downloads, account mutations or protection bypass.
## Bounded reproduction requests
These curl recipes show request syntax; **successful execution with local curl is not established**. One local curl probe was blocked. Check status/Content-Type and stop on a block page. Example IDs/dates describe the observed catalog snapshot; titles and availability can change.
```sh
BASE='https://barelystarted.miruro.tv'
ID='OERe_a0YS4h-DlsBDOeXwX7HOO6316tw'
SLUG='the-apothecary-diaries-season-3'
curl --silent --show-error --max-time 25 -i --get "$BASE/api/search/browse" \
--data-urlencode 'q=apothecary' --data-urlencode 'limit=8' --data-urlencode 'type=ANIME'
curl --silent --show-error --max-time 25 -i --get "$BASE/search/__data.json" \
--data-urlencode 'q=apothecary'
curl --silent --show-error --max-time 25 -i "$BASE/info/$ID/$SLUG/__data.json"
curl --silent --show-error --max-time 25 -i --get "$BASE/watch/$ID/$SLUG/__data.json" \
--data-urlencode 'ep=1'
curl --silent --show-error --max-time 25 -i --get "$BASE/api/sources" \
--data-urlencode "id=$ID" --data-urlencode 'n=1'
```
For full-search pagination, decode `nextCursor` from `/search/__data.json`, then request the same route with `--data-urlencode "cursor=$CURSOR"`, retaining filters/cookie context. For current schedule, decode `weekStart` from `/schedule/__data.json`, then request `/api/schedule` with `startAt=<that value>&track=all`. To inspect media resources, select fresh stream/subtitle URLs from the source response and use that server's actual headers, a bounded GET and in-memory results; do not save tokenized URLs to these notes.
Equivalent plain-JSON research request (runtime/network portability is unproven):
```js
const response = await fetch(
'https://barelystarted.miruro.tv/api/search/browse?q=apothecary&limit=8&type=ANIME',
{ signal: AbortSignal.timeout(20_000) }
);
console.log(response.status, response.headers.get('content-type'));
console.log(await response.text());
```
## Native Rust wrapper and verified probe
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.
Library interface, exposed as `tubular::miruro`:
| 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 |
The wrapper does not choose providers/languages or implement a player. It preserves JSON metadata rather than freezing every deployment-specific field into a second schema. IDs/slugs are validated as nonempty ASCII alphanumeric/hyphen/underscore path segments. HTTP failures remain errors (`PermissionDenied` for 401/403); HTML and unexpected Content-Types are rejected, not converted to empty results. No tokenized URLs appear in its error messages. Successful bodies are limited to 2 MiB.
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:
```sh
cargo run --locked --example miruro_probe
```
Actual output from the extracted wrapper:
```text
search: 8 records; selected Miruro ID OERe_a0YS4h-DlsBDOeXwX7HOO6316tw
detail: 2 episodes; selected episode 1
sources: 2 tracks; selected ssub/sun; 2 server headers retained
hls_master: HTTP 200; Content-Type application/vnd.apple.mpegurl
subtitle: HTTP 200; Content-Type text/vtt
PASS: native search -> detail/episodes -> sources -> HLS master + subtitle; no media segments or tokens saved
```
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.
Verification: `cargo test --locked` passed all 164 tests, including the new episode-promise regression; `cargo clippy --locked --all-targets -- -D warnings` passed; `rustfmt --edition 2024 --check src/lib.rs src/miruro.rs examples/miruro_probe.rs` passed. The decoder regression covers independent tables, out-of-order unrelated chunks, preserved episode/absolute numbers and nulls, unresolved/rejected promises, bad references/cycles, unsupported tags and route redirects.
## Open questions before TUI/player integration
- Will this same native client remain accessible on other networks/deployments? Its sampled path passed; local curl previously failed, and no workaround is implemented.
- Is there a stable plain metadata/episode API? The wrapper currently owns the bounded JSON-shaped SvelteKit decoder for the observed deployment.
- What are server-side filter/enum constraints, multi-value semantics, cursor lifetime, page-size-cookie limits, numeric-ID lookups and slug behavior?
- What happens for unavailable episodes, empty tracks, provider failures, bad cursors, rejected promise chunks and syntactically valid missing IDs?
- Which headers/config transforms are required per provider, and can Terebi propagate them to HLS playlists/segments/keys and external subtitles?
- Are direct streams usable end-to-end, including rendition/segment access and seeking? Only master/subtitle/thumbnail retrieval was verified.
- What are seeded discovery authentication requirements, rate limits, token expiry/IP binding and content-use permissions? Reachable internal endpoints do not imply a supported third-party integration or permission to redistribute content.
Hashed JavaScript URLs below identify the inspected deployment, not stable versions. For later research, re-read the homepage's entry-script imports and [route map][routes] to locate the current bundles; do not hardcode these hashes in the wrapper.
[routes]: https://barelystarted.miruro.tv/_app/immutable/entry/app.BSfZufle.js
[layout]: https://barelystarted.miruro.tv/_app/immutable/nodes/0.TgQ_FRp6.js
[search-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/5.B8psgBrv.js
[filter-options]: https://barelystarted.miruro.tv/_app/immutable/chunks/BFtFCiqs.js
[link-helpers]: https://barelystarted.miruro.tv/_app/immutable/chunks/DRUtYxUt.js
[detail-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/6.BPCeS58O.js
[watch-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/11.CVVa7Rmv.js
[home-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/2.D7a6D6w9.js
[trending-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/9.CWTtBRa5.js
[schedule-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/7.BJ4_XLlf.js
[discover-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/12.C2zcxgjP.js
[transport-client]: https://barelystarted.miruro.tv/_app/immutable/chunks/DGYr79W3.js
[homepage]: https://barelystarted.miruro.tv/
[strm-client]: https://strm.cx/client.js
[browse-live]: https://barelystarted.miruro.tv/api/search/browse?q=apothecary&limit=8&type=ANIME
[search-live]: https://barelystarted.miruro.tv/search/__data.json?q=apothecary
[search-all-live]: https://barelystarted.miruro.tv/search/__data.json
[empty-search-live]: https://barelystarted.miruro.tv/search/__data.json?q=zzzznoanimezzzz
[detail-live]: https://barelystarted.miruro.tv/info/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json
[missing-detail-live]: https://barelystarted.miruro.tv/info/invalid/invalid/__data.json
[watch-live]: https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json?ep=1
[sources-live]: https://barelystarted.miruro.tv/api/sources?id=OERe_a0YS4h-DlsBDOeXwX7HOO6316tw&n=1
[home-live]: https://barelystarted.miruro.tv/__data.json
[trending-live]: https://barelystarted.miruro.tv/trending/__data.json
[schedule-live]: https://barelystarted.miruro.tv/schedule/__data.json
[events-live]: https://barelystarted.miruro.tv/api/schedule?startAt=1791763200&track=all
[refs-live]: https://barelystarted.miruro.tv/api/discover/refs
[discover-live]: https://barelystarted.miruro.tv/discover/__data.json
[feed-live]: https://barelystarted.miruro.tv/discover/feed