Compare commits

...

4 Commits

12 changed files with 3649 additions and 137 deletions
Generated
+198
View File
@@ -79,6 +79,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64-simd"
version = "0.8.0"
@@ -139,6 +145,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
@@ -148,6 +160,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -361,6 +383,12 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -423,6 +451,22 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "icy_sixel"
version = "0.5.1"
@@ -654,6 +698,12 @@ dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
@@ -732,6 +782,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "png"
version = "0.18.1"
@@ -981,6 +1037,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -1016,6 +1086,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -1098,6 +1203,12 @@ dependencies = [
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -1174,6 +1285,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -1263,6 +1380,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
@@ -1313,6 +1436,8 @@ dependencies = [
"serde_json",
"time",
"toml_edit",
"unicode-normalization",
"ureq",
]
[[package]]
@@ -1321,6 +1446,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -1344,6 +1478,46 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -1362,6 +1536,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "wide"
version = "1.7.1"
@@ -1464,6 +1647,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -1584,6 +1776,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zlib-rs"
version = "0.6.8"
+3 -1
View File
@@ -2,7 +2,7 @@
name = "tubular"
version = "0.1.0"
edition = "2024"
description = "A terminal YouTube browser that opens selected videos in Terebi"
description = "A terminal YouTube and Miruro browser that opens selected videos in Terebi"
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
@@ -13,6 +13,8 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
toml_edit = "0.25.17"
time = "0.3.55"
unicode-normalization = "0.1.25"
ureq = { version = "3.4.2", default-features = false, features = ["rustls"] }
[target."cfg(unix)".dependencies]
libc = "0.2.190"
+48 -16
View File
@@ -1,13 +1,13 @@
# tubular
A modal terminal YouTube browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos, organize subscribed channels with overlapping tags, browse their feeds, and open a selected video in Terebi.
A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos or anime episodes, organize subscribed YouTube channels with overlapping tags, and open a selection in Terebi.
## Requirements
- A recent stable Rust toolchain, including Cargo, to build and install.
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for searches, feeds, and full descriptions.
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; Miruro uses the native API client.
- `curl` on `PATH` for thumbnails.
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. It must support `--new-instance --url … --progress-file PATH`.
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. YouTube requires `--new-instance --url … --progress-file PATH`; Miruro additionally requires `--source-file PATH` and version-2 episode progress support.
The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player.
@@ -34,6 +34,7 @@ cargo run --locked -- "rust programming"
```sh
tubular # Start without a query
tubular "rust programming" # Search immediately
tubular --miruro "apothecary" # Search Miruro instead
tubular --player /path/to/terebi "japanese podcast"
tubular --help
```
@@ -49,6 +50,15 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \
Each selection opens an independent player instance without replacing existing playback. Tubular creates a private reporting session and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running.
### Miruro episodes
Use `--miruro` at startup, or reach the permanent **Miruro** tab with Tab / Shift-Tab. Press `i` to edit its search and Enter to submit. Autocomplete returns at most eight catalog titles, without pagination. Enter on a title loads its returned episodes; Enter on an episode resolves a fresh direct **ssub / sun** HLS source and launches Terebi with a private source document. `/` still opens YouTube Search.
Episodes support watch later, saved videos, currently watching, and watched status. Only episodes—not catalog titles—can be saved. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Miruro entries appear in combined collections, not YouTube channel categories. Channel/tag actions and YouTube thumbnails/extraction do not apply.
Stream and external VTT subtitle headers are supplied explicitly; Terebi selects the source's declared default subtitle. Sources requiring media redirects, cross-origin HLS, proxies, iframe playback, or provider-specific playlist rewriting fail explicitly. No provider/language fallback or automatic expired-source refresh is performed.
## Controls
These are the default bindings. Overrides in `[keys]` replace an action's bindings; the live hints and `?` reference show the configured keys.
@@ -58,9 +68,9 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| Key | Action |
| --- | --- |
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
| Enter | Open the selected video in Terebi |
| Enter | Open a Miruro catalog title's episodes, or launch the selected video/episode in Terebi |
| `g` / Home, `G` / End | Select the first / last loaded video |
| `i` | Edit the current category/combined feed's search, or general Search from other views |
| `i` | Edit the current category/combined feed or Miruro search; other views use general YouTube Search |
| `/` | Go directly to general YouTube Search |
| `F` (Shift-F) | Go directly to the subscription Feed |
| `d` | Open video info; press again for the full description, again to return to info |
@@ -78,11 +88,12 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| `W` | Choose combined or category watch later from any video view or preview |
| `Alt-w` | Open the current category's watch later |
| `f` | In watch later, cycle All → Unwatched → Watched |
| `x` | In watch-later or saved-video lists, remove the selected video |
| `X` (Shift-X) | In either collection list, confirm bulk removal of the current category/filter subset |
| `x` | In watch-later, saved-video, or currently-watching lists, remove the selected video |
| `X` (Shift-X) | In collection lists, confirm bulk removal of the current list/category/filter subset |
| `v` | Save the selected video to saved videos (also works in previews) |
| `V` | Choose combined or category saved videos from any video view or preview |
| `Alt-v` | Open the current category's saved videos |
| `C` (Shift-C) | Open the combined Currently watching list |
| Tab / Shift-Tab | Switch between visible views |
| `r` | Reload configuration and refresh the current view |
| `?` | Open / close the scrollable keybinding reference |
@@ -204,7 +215,7 @@ Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC
Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config.
- **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates.
- **Search**, **Feed**, and **Miruro** are permanent tabs. Feed combines all YouTube `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates.
- Browsing an unpinned category, combined-category feed, channel, watch-later list, or saved-video list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`.
- Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`.
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
@@ -213,7 +224,7 @@ Bulk editing refuses to clear all tags when a target has no `[channels]` registr
## Watch later
Watch later is **one collection**, deduplicated by YouTube video ID. Category lists are derived from the saved videos' channel identities and the **current** subscription categories: adding or removing a channel from a category changes its watch-later list after saving tags or reloading config with `r`. No category memberships are stored on individual videos.
Watch later is **one collection**, deduplicated by YouTube video ID or stable Miruro episode identity. Category lists are derived from saved YouTube videos' channel identities and the **current** subscription categories: adding or removing a channel from a category changes its watch-later list after saving tags or reloading config with `r`. Miruro episodes appear only in the combined list. No category memberships are stored on individual videos.
`W` opens a searchable collection selector from any video view or preview. The All categories (combined) entry comes first, followed by configured categories; it remains available even when there are no categories. Navigate with `j` / `k` or arrows in normal mode, or press `i` to filter by name and use arrows / `Ctrl-j` / `Ctrl-k` while typing. `Enter` browses the selected match; with no matches it does nothing. `Esc` returns to normal mode, then closes the selector without navigating. Reopening clears the search. `Alt-w` still opens the current category's watch later directly.
@@ -231,16 +242,17 @@ Files are siblings of `config.toml`:
- `watch-later.toml`: versioned saved-video metadata.
- `saved-videos.toml`: independent versioned saved-video metadata.
- `currently-watching.toml`: versioned metadata for started, unfinished videos.
- `watch-state.toml`: versioned global watched video IDs.
- `playback-sessions/`: private per-launch progress streams and their expected video identities.
- `playback-sessions/`: private per-launch progress streams, expected identities, metadata snapshots, durable admission markers, and Miruro source documents.
State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB.
Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. They are retained because the GUI may still be writing; up to 4,096 sessions are monitored. If that limit is reached, close the corresponding players, remove their inactive session directories, and restart Tubular. Never remove an active player's reporting directory.
Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. Miruro source documents are `0600` files in `0700` session directories and remain unchanged, even after launcher exit or reported end/close; they contain private, expiring resource URLs and headers. Sessions are retained because CLI exit does not prove the independent GUI has finished; up to 4,096 are monitored. Close the corresponding players before removing their inactive session directories, then restart Tubular. Never remove an active player's directory.
## Saved videos
Saved videos is a separate collection from watch later, deduplicated by YouTube video ID. It uses the same offline title, creator, and publication metadata and follows **current channel category memberships**, not categories stored on individual videos.
Saved videos is a separate collection from watch later, deduplicated by YouTube video ID or stable Miruro episode identity. It uses the same offline title, creator, and publication metadata; YouTube entries follow **current channel category memberships**, not categories stored on individual videos. Miruro episodes appear in All categories (combined).
`V` opens the same searchable selector for saved videos, with **All categories (combined)** first and configured categories beneath it. `Alt-v` opens the current category's saved videos directly. `Alt-w` / `Alt-v` retain the explicit category when switching between category-scoped collection lists; without a current category, they leave the view unchanged. All four shortcuts work in normal mode and previews, not text-entry fields or modal selectors. Both collections share the existing temporary-tab and back/forward history behavior.
@@ -250,15 +262,25 @@ Saved titles are **green**, taking precedence over watch-later magenta when a vi
The collection is persisted independently in `saved-videos.toml` beside `config.toml`, using the same atomic, owner-only writes and validation as watch later. Saving repeatedly does not add duplicates. Configure saving and the collection selector with `video.saved.save` and `view.saved.browse`; direct category browsing uses `view.saved.category`. The corresponding watch-later navigation actions are `view.watch_later.browse` and `view.watch_later.category`. Selectors use the shared `selector.*` and `input.*` bindings. The live hints and `?` reference reflect overrides. Alt shortcuts require the terminal to transmit Alt/Meta; if macOS Option inserts symbols, enable Option-as-Meta or remap the actions.
## Currently watching
`C` opens one combined, offline list of started but unfinished videos. It uses the shared temporary tab and back/forward navigation; configure the shortcut with `view.watching.browse`.
A validated, acknowledged **positive playback position** adds the video—not merely launching Terebi or receiving position zero. Reaching the existing eligible **95% watched threshold** automatically removes it. Already-watched videos never enter. Unknown-duration or ineligible playback can appear but stays unfinished unless a later eligible report marks it watched or you remove it.
Entries and their title/creator/publication metadata survive restarts. Reports written while Tubular is closed are recovered on the next launch. Older sessions without video snapshots can populate the list when matching watch-later/saved metadata exists; otherwise they continue updating watched status without inventing missing metadata.
Use `x` for individual removal or `X` for bulk confirmation. Removal neither marks the video watched nor changes watch later/saved videos. Continued reports from existing sessions cannot re-add a removed entry, including after restart; deliberately starting a new session can re-add it if still unfinished.
## Collection removal
Removal controls apply **only in watch-later and saved-video views**, including previews opened from those lists. Search, Feed, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls.
Removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. Search, Feed, Miruro catalog/episode browsing, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls.
`x` immediately removes only the selected video from the active collection. `X` snapshots the current list and opens bulk confirmation: watch later uses the current category and All/Unwatched/Watched filter; saved videos uses the current category's entire saved list. An empty list does not open confirmation.
`x` immediately removes only the selected video from the active collection. `X` snapshots the current list and opens bulk confirmation: watch later uses the current category and All/Unwatched/Watched filter; saved videos uses the current category's entire saved list; Currently watching uses its entire combined list. An empty list does not open confirmation.
The prompt names the collection, category, count, and applicable filter. **`y` confirms; `n` or Esc cancels.** Enter never confirms. Only the snapshotted IDs are removed, even if membership or watched status changes while confirmation is open.
The prompt names the collection, category, count, and applicable filter. **`y` or Enter confirms; `n` or Esc cancels.** Only the snapshotted IDs are removed, even if membership or watched status changes while confirmation is open.
Removal changes only the active collection. Matching category views update, while the other collection and global watched status remain untouched.
Removal changes only the active collection. Matching views update, while other collections and global watched status remain untouched.
The old collection-specific removal settings are no longer accepted. Replace them with:
@@ -310,8 +332,18 @@ 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.
```sh
cargo fmt --check
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
```
Read-only native Miruro smoke (search → detail/episodes → fresh sources → HLS master/subtitle):
```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.
+531
View File
@@ -0,0 +1,531 @@
# 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.
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.
Tubular's direct Sun integration preserves fresh URLs and explicitly supplies the selected server's headers to both its stream and verified VTT subtitle resources. Terebi owns native HLS resolution and subtitle loading; other providers' proxies, headers and playlist transforms remain unverified and unsupported. See [the adopted integration contract](#tubular--terebi-integration).
## SvelteKit route data
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 |
| `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>` |
The raw methods preserve JSON metadata rather than freezing every deployment-specific field into a second schema. `source_document` deliberately selects only direct Sun soft-subtitle playback; it does not guess another provider or implement a player. IDs/slugs are validated as nonempty ASCII alphanumeric/hyphen/underscore path segments. HTTP failures remain errors (`PermissionDenied` for 401/403); HTML and unexpected Content-Types are rejected, not converted to empty results. No tokenized URLs appear in its error messages. Successful bodies are limited to 2 MiB.
Its private route decoder resolves only the needed detail value and episode promise, using separate value tables and promise IDs. Unrelated sidebar promises are not returned. It supports the JSON-shaped tables verified on this path, **not arbitrary devalue**: tagged special types and negative sentinels are explicit errors, never silently coerced to null. Out-of-range references, rejected/missing/duplicate episode chunks, cycles/excessive depth and decoded-size amplification are rejected. Depth is bounded to 64 and expanded values share a 2 MiB size budget. These are wrapper safety limits, not Miruro server limits.
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.
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 --miruro "apothecary"` starts the permanent Miruro search tab. Catalog Enter opens actual returned episodes; episode Enter resolves fresh sources off-thread. Autocomplete is limited to eight titles, without invented pagination. One worker handles bounded network work; changing views/queries invalidates stale responses rather than launching obsolete selections. Canonical title/slug rules follow the [deployed helper][link-helpers], including Unicode NFD normalization.
Titles normalizing to an empty slug fail explicitly; the wrapper does not guess an alternative detail route. This does not reject accented Latin titles, which use NFD decomposition.
### Source document and launch
`Client::source_document` chooses the first nonempty server/first direct HLS stream from the returned `ssub` track's `sun` provider. It requires native playback, `proxy:false`, and no enabled HLS host/query rewriting. Missing/unsupported sources are errors; no alternate language/provider, embed, proxy or refresh fallback is added.
| Terebi source-v1 field | Projection |
| --- | --- |
| `url` | Selected stream URL, unchanged, including its query tokens |
| `headers` | Selected server map, without trimming or rewriting |
| `identity` | `{provider:"miruro", media_id:<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:
```sh
terebi --new-instance --source-file '/private/session/source.json' \
--progress-file '/private/session/progress.jsonl'
```
Terebi exclusively creates the report (`0600`). CLI exit acknowledges only launch acceptance, not successful media opening or completion. Tubular retains source/report files across launcher exit, final events, and Tubular shutdown because the independent GUI may still need them. The existing 4,096-session cap and explicit inactive-session cleanup apply; close the corresponding players before removing their session directories. Private source documents contain expiring URLs/headers; watched/collection state contains only stable identity and display metadata.
Terebi's implemented transport preserves headers on master/variant/segment/key/init requests and probes Sun's image-suffixed media/subtitle resources by content/declared VTT format. All media redirects and foreign-origin HLS resources are rejected; subtitle redirects must stay on their initial origin. Terebi does not refresh expired sources. These restrictions remain visible errors, not silent provider switches.
### Progress and collections
Miruro JSONL reports use version 2:
```json
{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"opaque_ID","episode":1},"position_seconds":95.0,"duration_seconds":100.0,"completion_eligible":true}
```
Tubular validates the version, namespace, media ID, episode, event and numeric fields, then compares the complete identity against that launch's private session marker. Cross-episode/provider/media records cannot admit or complete another episode. YouTube's canonical `media_url` and version-1 validation remain supported, including its existing unknown-field tolerance.
Matching acknowledged positive positions admit currently-watching entries. Watched status retains `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`; null duration is ineligible. Seeking near the end can satisfy it. Launch/window closure alone cannot. Duplicate records, partial trailing lines and later reports recovered after Tubular restart retain the existing session-consumption behavior.
Watch later, saved videos, currently watching and watched state accept stable episode keys `miruro:<opaque ID>:<canonical episode>`. Catalog rows (`miruro-series:<ID>`) are not saveable/playable episode identities. Saved episode playback resolves fresh sources; CDN addresses/tokens never become collection keys. Miruro has no YouTube channel identity, so category/tag/channel actions and YouTube extraction/thumbnails are excluded; its episodes use combined collections.
### Integration verification
The actual Tubular terminal workflow was exercised with isolated `$XDG_CONFIG_HOME` state and the updated Terebi developer bundle selected by `--player`: autocomplete returned eight titles; the selected title returned two episodes; episode 1 was saved to both collections, freshly resolved, and launched through `State::miruro_session` / `PlayerLaunch::start_source`.
The private source document retained Origin/Referer headers and nine VTT tracks with English declared default. Session mode `0700` and source/report modes `0600` were checked without logging resource URLs or header values. Actual Terebi v2 records matched the selected opaque ID/episode and acknowledged position advanced from about 7 to 141 seconds, with positive duration and completion eligibility. Tubular admitted that identity to its visible Currently watching list and persisted source-free collection metadata.
After Tubular exited successfully, its independent GUI continued appending matching v2 progress and the private source file remained byte-for-byte unchanged. That player/session was intentionally retained for the user's visible subtitle/seek check; no source cleanup was attempted while playback remained active.
Final integrated checks passed: `cargo fmt --check`, all **175 tests**, `cargo clippy --locked --all-targets -- -D warnings`, `cargo build --locked`, and the built executable's `--help`. Regressions cover v1 compatibility, v2 identity/95%/null-duration boundaries, restart/partial-report recovery, source/session permissions and retention, collection routing, stale responses and canonical Unicode slugging. The installed Terebi CLI was older and lacked `--source-file`; the explicit developer-bundle override avoided changing that installation.
This run proves Tubular-to-Terebi launch, acknowledged playback/progress consumption and independent lifetime—not visible subtitle rendering, perceived A/V synchronization, or seeks through the user's installed release. English subtitle appearance/synchronization before and after seeking still requires a visible-app check.
## Remaining research questions
- Will this same native client remain accessible on other networks/deployments? Its sampled path passed; local curl previously failed, and no workaround is implemented.
- Is there a stable plain metadata/episode API? The wrapper currently owns the bounded JSON-shaped SvelteKit decoder for the observed deployment.
- What are server-side filter/enum constraints, multi-value semantics, cursor lifetime, page-size-cookie limits, numeric-ID lookups and slug behavior?
- What happens for unavailable episodes, empty tracks, provider failures, bad cursors, rejected promise chunks and syntactically valid missing IDs?
- Which headers/config transforms are required by providers other than the verified direct Sun path?
- Will other streams require redirects or cross-origin HLS resources that Terebi deliberately rejects? The Terebi team's handoff exercised direct Sun native decode, subtitles and acknowledged seeks, not every provider or comprehensive visible synchronization.
- What are seeded discovery authentication requirements, rate limits, token expiry/IP binding and content-use permissions? Reachable internal endpoints do not imply a supported third-party integration or permission to redistribute content.
Hashed JavaScript URLs below identify the inspected deployment, not stable versions. For later research, re-read the homepage's entry-script imports and [route map][routes] to locate the current bundles; do not hardcode these hashes in the wrapper.
[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
+159
View File
@@ -0,0 +1,159 @@
//! 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};
use serde_json::{Map, Value};
use tubular::miruro::Client;
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
fn main() -> io::Result<()> {
let client = Client::default();
let results = client.search("apothecary")?;
let media = results
.iter()
.find(|media| media["id"] == "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw")
.ok_or_else(|| invalid("Research title no longer appears in search"))?;
let id = media["id"]
.as_str()
.ok_or_else(|| invalid("Missing media ID"))?;
println!("search: {} records; selected Miruro ID {id}", results.len());
let detail = client.detail(id, "the-apothecary-diaries-season-3")?;
let number = detail
.episodes
.first()
.and_then(|episode| episode["episode_number"].as_u64())
.and_then(|number| u32::try_from(number).ok())
.ok_or_else(|| invalid("No episode metadata available"))?;
println!(
"detail: {} 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"
);
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"))
}
+7 -2
View File
@@ -142,6 +142,7 @@ actions! {
SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"];
SavedVideoBrowse, "view.saved.browse", "Choose saved videos across all categories or one category", VIEW, ["V"];
SavedVideoBrowseCategory, "view.saved.category", "Browse the current category's saved videos", VIEW, ["alt+v"];
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
@@ -538,16 +539,20 @@ impl Bindings {
),
],
),
(
"CURRENTLY WATCHING",
&[(&[WatchingBrowse], "Browse started, unfinished videos")],
),
(
"COLLECTION LISTS",
&[
(
&[CollectionRemoveSelected],
"Remove selected from current watch-later / saved list only",
"Remove selected from the current collection only",
),
(
&[CollectionRemoveBulk],
"Confirm removal of current category / filtered subset",
"Confirm removal of current list / category / filtered subset",
),
(
&[],
+1
View File
@@ -0,0 +1 @@
pub mod miruro;
+572 -49
View File
File diff suppressed because one or more lines are too long
+659
View File
@@ -0,0 +1,659 @@
//! Blocking access to the deployed Miruro catalog and source API.
//!
//! See `docs/miruro-api.md`. Source URLs are ephemeral. Playback projection is
//! limited to direct Sun soft-subtitle sources; no proxy, iframe or retry fallback.
use std::{
collections::{BTreeMap, HashSet},
io,
mem::size_of,
time::Duration,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
const BASE: &str = "https://barelystarted.miruro.tv";
const BODY_LIMIT: u64 = 2 * 1024 * 1024;
pub struct Client {
agent: ureq::Agent,
}
/// Catalog metadata and available episode metadata, not a playback guarantee.
pub struct Detail {
pub media: Value,
pub episodes: Vec<Value>,
}
/// Stable catalog identity, never a CDN address or playback-provider name.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Identity {
pub provider: String,
pub media_id: String,
pub episode: u32,
}
impl Identity {
pub fn new(media_id: String, episode: u32) -> io::Result<Self> {
let identity = Self {
provider: "miruro".into(),
media_id,
episode,
};
identity.validate()?;
Ok(identity)
}
pub fn validate(&self) -> io::Result<()> {
if self.provider != "miruro"
|| self.media_id.len() > 512
|| self.episode == 0
|| self.episode > i32::MAX as u32
{
return Err(invalid("Invalid Miruro episode identity"));
}
validate_segment(&self.media_id)
}
pub fn key(&self) -> String {
format!("miruro:{}:{}", self.media_id, self.episode)
}
pub fn from_key(key: &str) -> io::Result<Self> {
let (media_id, episode) = key
.strip_prefix("miruro:")
.and_then(|rest| rest.split_once(':'))
.ok_or_else(|| invalid("Invalid Miruro episode key"))?;
if episode.starts_with('0') || !episode.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid("Noncanonical Miruro episode key"));
}
Self::new(
media_id.into(),
episode
.parse()
.map_err(|_| invalid("Invalid Miruro episode number"))?,
)
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Subtitle {
pub url: String,
pub format: String,
pub language: String,
pub label: String,
pub default: bool,
pub headers: BTreeMap<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 {
agent: ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(25)))
.http_status_as_error(false)
.max_redirects(0)
.build()
.new_agent(),
}
}
}
impl Client {
/// Autocomplete summaries only: the deployed endpoint rejects cursor pagination.
pub fn search(&self, query: &str) -> io::Result<Vec<Value>> {
let query = query.trim();
if query.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Empty Miruro query",
));
}
let body = self.get(
"/api/search/browse",
&[("q", query), ("limit", "8"), ("type", "ANIME")],
&["application/json"],
)?;
let mut value: Value = serde_json::from_str(&body).map_err(invalid)?;
match value.get_mut("data").map(Value::take) {
Some(Value::Array(items)) => Ok(items),
_ => Err(invalid("Miruro search did not return a data array")),
}
}
/// `id` is Miruro's opaque ID; `slug` follows the deployed canonical link helper.
/// Resolves the episode promise without decoding unrelated sidebar promises.
pub fn detail(&self, id: &str, slug: &str) -> io::Result<Detail> {
validate_segment(id)?;
validate_segment(slug)?;
let body = self.get(
&format!("/info/{id}/{slug}/__data.json"),
&[],
&["application/json", "text/sveltekit-data"],
)?;
let detail = decode_detail(&body)?;
if detail.media.get("id").and_then(Value::as_str) != Some(id) {
return Err(invalid("Miruro detail ID did not match the requested ID"));
}
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
.get(format!("{BASE}{path}"))
.query_pairs(query.iter().copied())
.call()
.map_err(|_| io::Error::other("Miruro HTTP transport failed"))?;
let status = response.status().as_u16();
let mime = response.body().mime_type().unwrap_or("missing");
if status != 200 {
let kind = match status {
401 | 403 => io::ErrorKind::PermissionDenied,
404 => io::ErrorKind::NotFound,
_ => io::ErrorKind::Other,
};
return Err(io::Error::new(
kind,
format!("Miruro HTTP {status} ({mime}); no retry attempted"),
));
}
if !types.contains(&mime) {
return Err(invalid(format!("Unexpected Miruro Content-Type: {mime}")));
}
response
.body_mut()
.with_config()
.limit(BODY_LIMIT)
.read_to_string()
.map_err(|_| invalid("Miruro response was unreadable, timed out, or exceeded 2 MiB"))
}
}
fn invalid(message: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.to_string())
}
fn validate_segment(segment: &str) -> io::Result<()> {
if segment.is_empty()
|| !segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid Miruro ID or slug path segment",
));
}
Ok(())
}
fn reference(value: &Value) -> io::Result<usize> {
value
.as_u64()
.and_then(|index| usize::try_from(index).ok())
.ok_or_else(|| invalid("Invalid or unsupported devalue reference"))
}
fn field(table: &[Value], key: &str) -> io::Result<usize> {
reference(
table
.first()
.and_then(|root| root.get(key))
.ok_or_else(|| invalid(format!("Missing Miruro route field: {key}")))?,
)
}
fn decode_detail(body: &str) -> io::Result<Detail> {
let mut lines = body.lines().filter(|line| !line.trim().is_empty());
let initial: Value = serde_json::from_str(
lines
.next()
.ok_or_else(|| invalid("Empty Miruro route data"))?,
)
.map_err(invalid)?;
if initial.get("type").and_then(Value::as_str) == Some("redirect") {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"Miruro detail returned a route redirect",
));
}
if initial.get("type").and_then(Value::as_str) != Some("data") {
return Err(invalid("Unexpected Miruro route envelope"));
}
let node = initial
.get("nodes")
.and_then(Value::as_array)
.and_then(|nodes| nodes.get(1))
.filter(|node| node.get("type").and_then(Value::as_str) == Some("data"))
.ok_or_else(|| invalid("Missing Miruro detail route node"))?;
let table = node
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| invalid("Missing Miruro detail value table"))?;
let mut budget = BODY_LIMIT as usize;
let media = expand(table, field(table, "detail")?, 0, &mut budget)?;
let promise = table
.get(field(table, "episodes")?)
.and_then(Value::as_array)
.filter(|tag| tag.len() == 2 && tag[0].as_str() == Some("Promise"))
.ok_or_else(|| invalid("Missing Miruro episode promise"))?;
let promise_id = table
.get(reference(&promise[1])?)
.and_then(Value::as_u64)
.ok_or_else(|| invalid("Invalid Miruro episode promise ID"))?;
let mut episodes = None;
for line in lines {
let chunk: Value = serde_json::from_str(line).map_err(invalid)?;
if chunk.get("type").and_then(Value::as_str) != Some("chunk")
|| chunk.get("id").and_then(Value::as_u64) != Some(promise_id)
{
continue;
}
if episodes.is_some() || chunk.get("error").is_some() {
return Err(invalid("Miruro episode promise was duplicated or rejected"));
}
let table = chunk
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| invalid("Missing Miruro episode chunk table"))?;
episodes = match expand(table, 0, 0, &mut budget)? {
Value::Array(items) => Some(items),
_ => {
return Err(invalid(
"Miruro episode promise did not resolve to an array",
));
}
};
}
Ok(Detail {
media,
episodes: episodes.ok_or_else(|| invalid("Miruro episode promise never resolved"))?,
})
}
// ponytail: JSON-shaped detail/episode tables only; reject other devalue types
// rather than silently coerce them. Add tags only if the catalog actually needs them.
fn expand(table: &[Value], index: usize, depth: usize, budget: &mut usize) -> io::Result<Value> {
if depth >= 64 {
return Err(invalid("Miruro devalue reference cycle or excessive depth"));
}
let value = table
.get(index)
.ok_or_else(|| invalid("Miruro devalue reference out of bounds"))?;
let cost = size_of::<Value>() + value.as_str().map_or(0, str::len);
*budget = budget
.checked_sub(cost)
.ok_or_else(|| invalid("Miruro decoded data exceeded its size budget"))?;
match value {
Value::Object(object) => {
let mut decoded = Map::new();
for (key, value) in object {
*budget = budget
.checked_sub(key.len())
.ok_or_else(|| invalid("Miruro decoded keys exceeded their size budget"))?;
decoded.insert(
key.clone(),
expand(table, reference(value)?, depth + 1, budget)?,
);
}
Ok(Value::Object(decoded))
}
Value::Array(array) => {
if array.first().is_some_and(Value::is_string) {
return Err(invalid("Unsupported Miruro devalue tagged type"));
}
array
.iter()
.map(|value| expand(table, reference(value)?, depth + 1, budget))
.collect::<io::Result<Vec<_>>>()
.map(Value::Array)
}
primitive => Ok(primitive.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() {
let sources = serde_json::json!({
"episodeNumber": 1,
"config": { "sun": { "player": "native", "proxy": false } },
"tracks": [{ "track": "ssub", "providers": [{
"provider": "sun",
"servers": [{
"headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" },
"streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }]
}],
"subtitles": [{
"file": "https://vault.keeply.top/english.jpeg", "format": "vtt",
"language": "en", "label": "English", "default": true
}]
}] }]
});
let identity = Identity::new("catalog".into(), 1).unwrap();
let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap();
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
assert_ne!(
Identity::new("catalog".into(), 2).unwrap().key(),
source.identity.key()
);
for key in [
"miruro:catalog:01",
"miruro:catalog:0",
"miruro:catalog:2147483648",
"miruro:catalog/:1",
] {
assert!(Identity::from_key(key).is_err());
}
for (pointer, value) in [
("/episodeNumber", serde_json::json!(49)),
("/config/sun/proxy", serde_json::json!({"segments": true})),
(
"/tracks/0/providers/0/servers/0/streams/0/url",
serde_json::json!("file:///secret"),
),
(
"/tracks/0/providers/0/servers/0/streams/0/embed",
serde_json::json!({"url": "https://embed.example"}),
),
(
"/tracks/0/providers/0/servers/0/headers/origin",
serde_json::json!("secret"),
),
(
"/tracks/0/providers/0/servers/0/headers/Referer",
serde_json::json!("secret\r\nX-Injected: true"),
),
] {
let mut invalid_sources = sources.clone();
if let Some(field) = invalid_sources.pointer_mut(pointer) {
*field = value;
} else {
invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] =
value;
}
let error =
SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1")
.unwrap_err();
assert!(!error.to_string().contains("secret"));
}
let mut invalid_source = source;
invalid_source
.subtitles
.push(invalid_source.subtitles[0].clone());
assert!(invalid_source.validate().is_err());
invalid_source.subtitles[1].default = false;
invalid_source.subtitles[1].format = "srt".into();
assert!(invalid_source.validate().is_err());
}
#[test]
fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() {
let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#;
let unrelated = r#"{"type":"chunk","id":4,"data":["unrelated"]}"#;
let episode_chunk = r#"{"type":"chunk","id":9,"data":[[1],{"episode_number":2,"absolute_number":3,"title":4,"canon_type":5},1,49,"Locusts",null]}"#;
let detail = decode_detail(&format!("{initial}\n{unrelated}\n{episode_chunk}\n")).unwrap();
assert_eq!(detail.media["episode_count"], 12);
assert_eq!(detail.episodes[0]["episode_number"], 1);
assert_eq!(detail.episodes[0]["absolute_number"], 49);
assert_eq!(detail.episodes[0]["title"], "Locusts");
assert!(detail.episodes[0]["canon_type"].is_null());
for chunk in [
"", // The promised episodes never arrive.
r#"{"type":"chunk","id":9,"error":["failed"]}"#,
r#"{"type":"chunk","id":9,"data":[[999]]}"#,
r#"{"type":"chunk","id":9,"data":[[0]]}"#, // Reference cycle.
r#"{"type":"chunk","id":9,"data":[["Date","2026-10-12"]]}"#,
] {
assert_eq!(
decode_detail(&format!("{initial}\n{chunk}"))
.err()
.unwrap()
.kind(),
io::ErrorKind::InvalidData
);
}
assert_eq!(
decode_detail(r#"{"type":"redirect","location":"/"}"#)
.err()
.unwrap()
.kind(),
io::ErrorKind::NotFound
);
}
}
+276
View File
@@ -0,0 +1,276 @@
use crate::youtube::Video;
use serde_json::Value;
use std::{
io,
sync::mpsc::{self, Receiver, SyncSender},
thread,
};
use tubular::miruro::{Client, Detail, Identity, SourceDocument};
use unicode_normalization::UnicodeNormalization;
pub enum Request {
Search(String),
Episodes { media_id: String, slug: String },
Source(Video),
}
pub enum Response {
Rows(Vec<(Video, String)>),
Source(Video, Box<SourceDocument>),
}
// One running request, no queued network requests. The UI retains only its newest intent.
pub struct Worker {
sender: SyncSender<(u64, Request)>,
receiver: Receiver<(u64, io::Result<Response>)>,
pub busy: bool,
generation: u64,
}
impl Worker {
pub fn new() -> Self {
let (sender, requests) = mpsc::sync_channel::<(u64, Request)>(1);
let (results, receiver) = mpsc::sync_channel(1);
thread::spawn(move || {
let client = Client::default();
while let Ok((generation, request)) = requests.recv() {
let result = match request {
Request::Search(query) => client
.search(&query)
.and_then(catalog_rows)
.map(Response::Rows),
Request::Episodes { media_id, slug } => client
.detail(&media_id, &slug)
.and_then(|detail| episode_rows(&media_id, detail))
.map(Response::Rows),
Request::Source(video) => Identity::from_key(&video.id)
.and_then(|id| {
client.source_document(&id.media_id, id.episode, &video.title)
})
.map(|source| Response::Source(video, Box::new(source))),
};
if results.send((generation, result)).is_err() {
break;
}
}
});
Self {
sender,
receiver,
busy: false,
generation: 0,
}
}
pub fn submit(&mut self, generation: u64, request: Request) -> io::Result<()> {
if self.busy {
return Err(io::Error::other("Miruro worker is busy"));
}
self.sender
.try_send((generation, request))
.map_err(|_| io::Error::other("Miruro worker unavailable"))?;
self.busy = true;
self.generation = generation;
Ok(())
}
pub fn poll(&mut self) -> Option<(u64, io::Result<Response>)> {
match self.receiver.try_recv() {
Ok(result) => {
self.busy = false;
Some(result)
}
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) if self.busy => {
self.busy = false;
Some((
self.generation,
Err(io::Error::other("Miruro worker stopped")),
))
}
Err(_) => None,
}
}
}
pub fn is_miruro(video: &Video) -> bool {
video.id.starts_with("miruro:") || video.id.starts_with("miruro-series:")
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
fn title(media: &Value) -> io::Result<String> {
let get = |language| {
media
.get("title")
.and_then(|v| v.get(language))
.and_then(Value::as_str)
.map(|text| text.trim_matches(js_whitespace))
.filter(|s| !s.is_empty())
};
let selected = match (get("english"), get("romaji")) {
(Some(a), Some(b)) => {
if a.encode_utf16().count() <= b.encode_utf16().count() {
a
} else {
b
}
}
(Some(a), None) | (None, Some(a)) => a,
_ => media
.get("title")
.and_then(|value| value.get("native"))
.and_then(Value::as_str)
.or_else(|| media.get("id").and_then(Value::as_str))
.ok_or_else(|| invalid("Miruro title is missing"))?,
};
Ok(selected.to_owned())
}
// ECMAScript \s/trim, not Rust's Unicode whitespace (which includes U+0085).
fn js_whitespace(ch: char) -> bool {
matches!(ch, '\u{0009}'..='\u{000d}' | ' ' | '\u{00a0}' | '\u{1680}'
| '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}'
| '\u{205f}' | '\u{3000}' | '\u{feff}')
}
pub fn slug(title: &str) -> io::Result<String> {
let cleaned: String = title
.nfd()
.filter(|ch| !('\u{0300}'..='\u{036f}').contains(ch))
.flat_map(char::to_lowercase)
.filter(|ch| {
ch.is_ascii_lowercase() || ch.is_ascii_digit() || js_whitespace(*ch) || *ch == '-'
})
.collect();
let mut slug = String::new();
for ch in cleaned.trim_matches(js_whitespace).chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
} else if (js_whitespace(ch) || ch == '-') && !slug.ends_with('-') {
slug.push('-');
}
}
if slug.is_empty() {
Err(invalid("Miruro title has no representable route slug"))
} else {
Ok(slug)
}
}
fn video(id: String, title: String, date: Option<String>) -> Video {
Video {
id,
title,
channel: Some("Miruro".into()),
channel_id: None,
uploader_id: None,
timestamp: None,
publication_date: date,
}
}
pub fn catalog_rows(items: Vec<Value>) -> io::Result<Vec<(Video, String)>> {
items
.into_iter()
.map(|media| {
let id = media
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| invalid("Miruro catalog ID is missing"))?;
Identity::new(id.to_owned(), 1)?;
let title = title(&media)?;
let description = media
.get("description")
.and_then(Value::as_str)
.unwrap_or(
"Catalog title: Enter opens returned episodes; only episodes can be saved.",
)
.to_owned();
Ok((
video(format!("miruro-series:{id}"), title, None),
description,
))
})
.collect()
}
pub fn episode_rows(media_id: &str, detail: Detail) -> io::Result<Vec<(Video, String)>> {
let series = title(&detail.media)?;
let mut seen = std::collections::HashSet::new();
detail
.episodes
.into_iter()
.map(|episode| {
let number = episode
.get("episode_number")
.and_then(Value::as_u64)
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| invalid("Miruro episode number is invalid"))?;
let identity = Identity::new(media_id.to_owned(), number)?;
if !seen.insert(number) {
return Err(invalid("Miruro returned duplicate episode numbers"));
}
let label = episode.get("title").and_then(Value::as_str).unwrap_or("");
let title = format!(
"{series} · Episode {number}{}",
if label.is_empty() {
String::new()
} else {
format!(" · {label}")
}
);
let description = episode
.get("synopsis")
.and_then(Value::as_str)
.unwrap_or("Episode metadata unavailable.")
.to_owned();
let date = episode
.get("aired_on")
.and_then(Value::as_str)
.map(str::to_owned);
Ok((video(identity.key(), title, date), description))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_and_actual_episode_numbers_remain_distinct() {
let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}});
let rows = catalog_rows(vec![media.clone()]).unwrap();
assert_eq!(rows[0].0.id, "miruro-series:opaque_ID");
assert!(Identity::from_key(&rows[0].0.id).is_err());
let episodes = episode_rows(
"opaque_ID",
Detail {
media,
episodes: vec![
serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned"}),
],
},
)
.unwrap();
let identity = Identity::from_key(&episodes[0].0.id).unwrap();
assert_eq!(identity.episode, 3);
assert_eq!(identity.media_id, "opaque_ID");
assert!(episodes[0].0.channel_id.is_none());
assert!(slug("薬屋").is_err());
assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title");
assert_eq!(slug(" --Title-- ").unwrap(), "-title-");
assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu");
assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe");
assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i");
assert_eq!(
slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(),
"a-b-c"
);
assert_eq!(slug("A\u{0085}B").unwrap(), "ab");
assert!(slug("!!薬屋!!").is_err());
assert_eq!(
title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(),
"AB"
);
assert_eq!(
title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(),
"BC"
);
assert_eq!(
title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(),
""
);
}
}
+1135 -62
View File
File diff suppressed because it is too large Load Diff
+55 -2
View File
@@ -819,14 +819,67 @@ impl PlayerLaunch {
));
}
crate::video_state::register_identity(progress_file, id)?;
let mut player = isolated_command(player)
let mut command = isolated_command(player);
command
.args([
"--new-instance",
"--url",
&format!("https://www.youtube.com/watch?v={id}"),
])
.arg("--progress-file")
.arg(progress_file)
.arg(progress_file);
Self::launch(command)
}
/// Session files are owned and retained by video_state, not by this launcher.
pub fn start_source(
source_file: &Path,
player: &OsStr,
progress_file: &Path,
) -> io::Result<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)
.arg("--progress-file")
.arg(progress_file);
Self::launch(command)
}
fn launch(mut command: Command) -> io::Result<Self> {
let mut player = command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())