Compare commits
4 Commits
28c94eb70b
...
0e909001d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e909001d0 | |||
| 0fe24ff302 | |||
| af282e2a81 | |||
| a6557047fe |
Generated
+198
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
(
|
||||
&[],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod miruro;
|
||||
+572
-49
File diff suppressed because one or more lines are too long
+659
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+55
-2
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user