Compare commits
12 Commits
2279004e91
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8a3451c4 | |||
| 65200626d2 | |||
| 6cc4e18f72 | |||
| 19267d169b | |||
| 5c0b7879e3 | |||
| a5aea5adcf | |||
| e328ff3883 | |||
| a281c6730a | |||
| baa91d4091 | |||
| ae87bb8b36 | |||
| 090f077eeb | |||
| 96bfc96656 |
Generated
+1
@@ -1517,6 +1517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
name = "tubular"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "A terminal YouTube and Miruro browser that opens selected videos in Terebi"
|
||||
description = "A terminal YouTube and AniList browser that opens selected videos in Terebi"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.7", features = ["derive"] }
|
||||
@@ -16,7 +16,7 @@ toml_edit = "0.25.17"
|
||||
time = "0.3.55"
|
||||
unicode-normalization = "0.1.25"
|
||||
unicode-segmentation = "1.13.3"
|
||||
ureq = { version = "3.4.2", default-features = false, features = ["rustls"] }
|
||||
ureq = { version = "3.4.2", default-features = false, features = ["rustls", "gzip"] }
|
||||
|
||||
[target."cfg(unix)".dependencies]
|
||||
libc = "0.2.190"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
# tubular
|
||||
|
||||
A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos or anime episodes, organize YouTube channels and saved Miruro shows with overlapping tags, and open a selection in Terebi.
|
||||
A modal terminal YouTube and AniList browser built with Rust and [Ratatui](https://ratatui.rs/). Search videos or anime catalog titles, organize channels and saved shows with overlapping tags, and open a selection in Terebi. The explicitly separate Miruro legacy catalog and library remain accessible.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A recent stable Rust toolchain, including Cargo, to build and install.
|
||||
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; Miruro uses the native API client.
|
||||
- `curl` on `PATH` for YouTube thumbnails and Miruro cover art.
|
||||
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. Single videos use `--new-instance --url … --progress-file PATH`; multiple videos require `--new-instance --queue-file PATH` support. Miruro also requires native episode-URL resolution and version-2 episode progress support.
|
||||
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; AniList and legacy Miruro catalog requests use native HTTPS.
|
||||
- `curl` on `PATH` for thumbnails and anime cover art.
|
||||
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. Single videos use `--new-instance --url … --progress-file PATH`; queues require `--new-instance --queue-file PATH`. AniList playback requires the ZokoAnime sub resolver and version-2 `anilist` identity progress support; legacy Miruro retains its own resolver and identities.
|
||||
|
||||
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.
|
||||
|
||||
YouTube extraction uses IPv4 to avoid anti-bot failures on affected IPv6 routes. Terebi must be updated separately for the same playback fix; rebuilding Tubular alone does not change its bundled extractor invocation. No browser cookies are read.
|
||||
|
||||
A terminal with native image support can display thumbnails and cover art using its graphics protocol. Other terminals fall back to colored half-block characters.
|
||||
|
||||
## Installation
|
||||
@@ -32,20 +34,21 @@ cargo run --locked -- "rust programming"
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
tubular # Choose YouTube or Miruro
|
||||
tubular # Choose YouTube, AniList, or Miruro legacy
|
||||
tubular "rust programming" # Choose a provider, then search this query
|
||||
tubular --miruro "apothecary" # Skip the chooser and search Miruro directly
|
||||
tubular --anilist "Cowboy Bebop" # Native AniList catalog; resolve sub on selection
|
||||
tubular --miruro "apothecary" # Explicit legacy Miruro browsing
|
||||
tubular --player /path/to/terebi "japanese podcast"
|
||||
tubular --help
|
||||
```
|
||||
|
||||
Startup shows a centered **YouTube / Miruro** selector. Use `j` / `k` or arrows to select and Enter to open that provider's separate interface; Esc, `q`, or Ctrl-C quits without opening a browser. Navigation, confirmation and Esc use the configured `selector.select.next`, `selector.select.previous`, `selector.open` and `selector.close` bindings; quit/interrupt bindings also apply. An initial CLI query searches whichever provider you select. `--miruro` bypasses the chooser; `--help` and `--default-config` remain noninteractive.
|
||||
Startup shows a centered **YouTube / AniList / Miruro legacy** selector. Use `j` / `k` or arrows to select and Enter to open; Esc, `q`, or Ctrl-C quits. Configured selector/quit keys apply. An initial CLI query searches only the chosen catalog. `--anilist` and `--miruro` bypass the chooser and are mutually exclusive; `--help` and `--default-config` remain noninteractive.
|
||||
|
||||
After selection, the browser starts in **normal mode**. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting.
|
||||
|
||||
In either provider's normal mode, press `p` (`app.provider`) to reopen the chooser with the current provider selected, then Enter to switch. Esc cancels and returns to browsing (unlike at startup); `q` or Ctrl-C quits. Both browser sessions stay in memory, preserving their queries, results, selection and navigation history when you switch back. Switching leaves running players unaffected and never reapplies the CLI query: it is used only for the initially opened provider. `--miruro` only skips the startup chooser; you can still switch providers with `p`.
|
||||
In normal mode, `p` (`app.provider`) reopens the chooser with the current selection. Enter switches; Esc cancels; `q` or Ctrl-C quits. All three browser sessions retain their own queries, results, selection and history. Shared persisted state and pending progress follow the active browser, without mixing provider-scoped collections or show libraries. Switching never interrupts players or reapplies the initial CLI query.
|
||||
|
||||
Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. Entries use two lines: the title above dimmed metadata. YouTube shows the channel and publication date beneath each video title, without separate channel/date columns; tagged channels remain cyan. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `✓` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and title/episode metadata.
|
||||
The browsers share a cyan view heading, bordered search/results/status, and a dim key hint bar. Rows place the title above metadata; YouTube channel/publication/duration behavior is unchanged. Saved titles are green, watch-later titles magenta, and `✓` marks watched entries. AniList and legacy Miruro reuse the anime catalog/series/episode interface, cover-art previews, overlapping show tags, checked queues and offline episode collections.
|
||||
YouTube's dimmed metadata row includes duration as `M:SS` or `H:MM:SS`, or `Unknown duration` when unavailable. Duration is retained in saved collections; older entries without duration remain readable.
|
||||
|
||||
YouTube keeps fast flat listings, then resolves visible videos' original-preferred titles asynchronously, one metadata request at a time. Titles replace the listing text left to right at 20 ms per terminal column, using the smallest groups of whole user-perceived characters with matching column widths. Two narrow characters and one wide character therefore swap together after 40 ms, without shifting the untouched suffix or inserting temporary blanks. Only an exhausted shorter title is padded with trailing spaces; that padding disappears when the animation finishes. Offscreen changes apply immediately, and selection stays on the same video. Results are cached by video ID for the session; failures keep the listing title without automatic retries. This adds background yt-dlp requests, and opening video info can make a separate metadata request. “Original-preferred” uses the full extractor response, not a guarantee that YouTube will never return localized text.
|
||||
@@ -61,23 +64,58 @@ Plain Enter opens one independent player instance without replacing existing pla
|
||||
|
||||
### Multiple video selection
|
||||
|
||||
In video and episode lists, **Space** checks/unchecks the hovered row; **Esc** discards checks. **Enter** plays checked rows in their current displayed order, regardless of the order they were checked. With no checks, Enter plays the hovered row. These controls also work in collection views and metadata previews; Miruro catalog titles and saved shows cannot be checked.
|
||||
In video and episode lists, **Space** checks/unchecks the hovered row; **Esc** discards checks. **Enter** plays checked rows in the order they were checked, regardless of their current displayed order. Unchecking and rechecking a row moves it to the end. With no checks, Enter plays the hovered row. These controls also work in collection views and metadata previews; Miruro catalog titles and saved shows cannot be checked.
|
||||
|
||||
Selection and watched state share one marker slot: `*` for checked rows, otherwise `✓` for watched rows, otherwise blank. Checking a row only overrides its displayed marker, not its stored watch state. Category and tag selectors keep their checkboxes.
|
||||
|
||||
In normal browsing, Esc discards checks even when none are selected; `-` remains back navigation. Popups and text inputs keep their existing Esc close/cancel behavior. Ctrl-Space remains an alternative way to clear checks.
|
||||
|
||||
Checks belong to each cached view/screen and follow stable video identities through pagination and reordering. Refresh/replacement queries clear them; collection filtering/removal prunes checks for hidden/removed rows. Successful launcher creation clears checks, while immediate launch failures retain them. Saving, removing and marking watch status still affect the hovered video, not every checked row. Configure `video.select.toggle` and `video.select.clear`; terminals must transmit Ctrl-Space, or bind another chord.
|
||||
Checks belong to each cached view/screen and preserve check order by stable video identity through pagination and reordering. Refresh/replacement queries clear them; collection filtering/removal prunes checks for hidden/removed rows. Successful launcher creation clears checks, while immediate launch failures retain them. Saving, removing and marking watch status still affect the hovered video, not every checked row. Configure `video.select.toggle` and `video.select.clear`; terminals must transmit Ctrl-Space, or bind another chord.
|
||||
|
||||
Two or more checked videos launch `terebi --new-instance --queue-file '/private/session/queue.json'`. The private version-1 manifest contains ordered canonical episode/video page URLs and one independent `progress_file` per item. Terebi reuses its queue navigation and resolves each item when selected, not in advance; no expiring playback sources are stored. Each item's watching/watched state and resume identity remain independent, including after Tubular restarts. Single-video launches retain the command above. See the [queue contract](docs/terebi-url-playback-handoff.md#ordered-stream-queue-handoff).
|
||||
|
||||
**Ctrl+Return** (`video.enqueue`) sends the same checked rows, or the hovered row when none are checked, to a running Terebi queue without replacing its current playback. The terminal must transmit the modified Return, or you can rebind the action. Enqueue always uses `terebi --enqueue --queue-file '/private/session/queue.json'`, even for one video, preserving independent per-item progress. If Terebi is not running, it starts with that queue. Plain Return still opens a new instance. These shortcuts do nothing on Miruro catalog titles or saved shows and do not submit text inputs.
|
||||
**Ctrl+Return** (`video.enqueue`) sends the same checked rows in check order, or the hovered row when none are checked, to a running Terebi queue without replacing its current playback. The terminal must transmit the modified Return, or you can rebind the action. Enqueue always uses `terebi --enqueue --queue-file '/private/session/queue.json'`, even for one video, preserving independent per-item progress. If Terebi is not running, it starts with that queue. Plain Return still opens a new instance. These shortcuts do nothing on Miruro catalog titles or saved shows and do not submit text inputs.
|
||||
|
||||
Tubular requests the Kitty keyboard protocol's escape-code disambiguation for its entire terminal session and restores the previous mode on exit. Supporting terminals can then distinguish Ctrl+Return from plain Return. A terminal or multiplexer that does not support/forward this protocol may still send identical bytes for both; Tubular cannot distinguish them. In that case, configure another key, for example `[keys.video]` with `enqueue = "alt+e"`.
|
||||
|
||||
### Miruro browser
|
||||
### AniList catalog and on-demand sub playback
|
||||
|
||||
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
|
||||
Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: gzip response compression, a 20-second timeout, no redirects, 2 MiB limits on both wire and decompressed response bodies, HTTP/content-type checks and GraphQL-error rejection. Searches return at most eight titles. Nullable artwork, descriptions, scores, planned totals and airing metadata remain unknown rather than being replaced with fabricated values.
|
||||
|
||||
Opening an AniList search result builds catalog episode rows immediately from the metadata already fetched, without a second detail request. `r` still fetches fresh metadata; opening a saved-library show also fetches current detail. Back/forward retains the loaded screen.
|
||||
|
||||
**Catalog episode numbers are not source availability.** A finished show's known total supplies catalog episode numbers; a releasing show's `nextAiringEpisode.episode - 1` supplies an airing/catalog count, separate from its planned total. Unreleased shows have no aired numbers. Missing totals/airing information remain unknown, not “zero episodes.” In an unknown-count series, press `i` (configured search/edit), enter a positive canonical episode number, then Enter to select it. Enter again launches that explicit episode. The direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons.
|
||||
|
||||
AniList playable rows in series and offline episode collections have three aligned lines: title, existing metadata/watch markers, and a quiet source-check result. The third line starts as `Source check pending`, becomes `Checking source…`, then shows `ZokoAnime · sub`, `No source found` (only an explicit missing-episode response), or `Source check failed` (network/protection/malformed responses, manifest errors, an older CLI, or launcher failure). A positive check means a fresh source configuration and accessible valid root HLS manifest, **not** a guarantee that every segment or the full episode will play.
|
||||
|
||||
Checks run progressively through the configured `--player` executable's headless `--check-source URL` command, one at a time: selected episode first, then only rows actually visible in the current viewport. There is no whole-series scan. Moving the cursor within visible rows lets the current check finish; scrolling it offscreen, navigating away, loading catalog metadata, opening a popup/input/provider chooser, or closing Tubular cancels pending work. Returning to an unobscured episode view resumes checks after its next rendered frame. Each check has a 30-second timeout and a 4 KiB output cap; cancellation kills and reaps the isolated CLI child without a background reader thread.
|
||||
|
||||
Results are session-only by stable episode identity, including failures, so errors do not retry every tick. `r` clears this cache and refreshes the view. Checks do not change saved episodes, watched state, progress, config or stored metadata, and never store resolved URLs. Playback remains available for every status and always resolves the selected episode fresh; a check is not reused as playable media. An older Terebi CLI shows `Source check failed` without disabling playback. A successful handoff does not mark an episode watched.
|
||||
|
||||
The second line retains series-average runtime (`25 min avg`), format and series year followed by collection/watch markers. Missing or zero average runtimes are omitted. These are series-level facts, not measured episode durations or individual air dates; legacy Miruro retains its episode-specific dates/durations and two-line rows. Every line reserves the same checked/watched marker width so its text aligns with the episode title.
|
||||
|
||||
|
||||
Identities are `anilist:<positive decimal AniList ID>:<positive episode>`, with both numbers bounded by Int32. The stable handoff is `https://zokoanime.video/stream/ani/ID/EPISODE/sub` (`ani`, not the broken `anilist` route). Sources, tokens and subtitle URLs are never stored in collections or playback snapshots. Queues use the same stable URL and per-item private progress files; version-2 reports identify `provider = "anilist"`, decimal-string `media_id`, and numeric `episode`.
|
||||
|
||||
`anilist-library.toml` stores AniList shows/tags separately from the untouched `miruro-library.toml`. Shared episode collection/state files use exact provider-qualified keys and filter each catalog independently; `miruro:1:1` is not `anilist:1:1`. No opaque Miruro IDs, saved show metadata or old playback sessions are mapped or reinterpreted.
|
||||
|
||||
The controls in the legacy table below also apply to AniList, except unknown-count series use `i` for explicit episode selection. Saved AniList shows reopen current native detail by ID. The display limit is 10,000 known catalog numbers; oversized counts report an error instead of truncating silently.
|
||||
|
||||
Read-only native smoke (does not launch playback):
|
||||
|
||||
```sh
|
||||
cargo run --locked --example anilist_probe
|
||||
cargo test --locked
|
||||
cargo run --locked -- --anilist "Cowboy Bebop"
|
||||
```
|
||||
|
||||
The probe searches the real catalog, selects exact AniList ID 1, fetches detail, and emits the episode-1 identity/stable URL without checking a source. It does not prove playback. For a read-only provider smoke, run `terebi --check-source https://zokoanime.video/stream/ani/1/1/sub`; it prints one version-1 JSON line and does not launch the app. See `docs/anilist-direct-provider-feasibility.md` for historical native source/media evidence and outstanding AniList terms/distribution caveats.
|
||||
|
||||
### Miruro legacy browser
|
||||
|
||||
Choose **Miruro legacy** or use `--miruro`. This explicitly retained catalog has its own **catalog → series → returned episodes** session and saved-show library; it is never consulted for AniList browsing or playback.
|
||||
|
||||
Catalog and episode-metadata requests use the official Miruro 2.0 mirrors (`miruro.cx`, `.pw`, `.ws`, `.to`, `.tv`, `.bz`), not their legacy `www.` versions. Each session starts with `.cx`, remembers the last working mirror, and tries each mirror at most once when a host is unreachable, blocked, redirected, unavailable, or returns an unexpected Content-Type/unreadable body. Requests have a shared 25-second budget with at most five seconds per mirror; rate limits and other invalid/unauthorized requests stop immediately. No per-request randomization, directory scraping, cookies, or Cloudflare bypass is used. If every mirror is blocked, browsing still fails with the number of mirrors attempted and the last failure. Playback retains Terebi's existing `barelystarted.miruro.tv` URL contract; catalog failover does not add playback-mirror support.
|
||||
|
||||
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for cover art, a bold title, dimmed metadata and a synopsis excerpt, using the same info layout as YouTube. Press `d` again for the full scrollable synopsis and returned facts; another `d` returns to info, and Esc closes either mode. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first, above the series synopsis and beside the series cover art in info mode. Listed counts do not guarantee a playable source; Terebi resolves playback.
|
||||
|
||||
@@ -87,7 +125,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
|
||||
| `p` | Reopen the provider chooser in normal mode |
|
||||
| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode |
|
||||
| Space / Esc | Check/uncheck the hovered episode / discard episode checks |
|
||||
| Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) |
|
||||
| Enter | Open a series, or play checked episodes in check order (hovered episode when none are checked) |
|
||||
| Ctrl+Return | Append checked episodes, or the hovered episode, to a running Terebi queue (`video.enqueue`) |
|
||||
| `y` | Copy the hovered episode's page URL (also in previews and offline collections; `video.copy_url`) |
|
||||
| `-` / `+` | Back / forward through cached screens, preserving query and selection |
|
||||
@@ -124,7 +162,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
||||
| --- | --- |
|
||||
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
|
||||
| Space / Esc | Check/uncheck the hovered video / discard checks |
|
||||
| Enter | Play checked videos in displayed order, or the hovered video when none are checked |
|
||||
| Enter | Play checked videos in check order, or the hovered video when none are checked |
|
||||
| Ctrl+Return | Append checked videos, or the hovered video, to a running Terebi queue (`video.enqueue`) |
|
||||
| `p` | Reopen the provider chooser |
|
||||
| `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) |
|
||||
@@ -140,6 +178,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
||||
| `c` | Browse saved categories |
|
||||
| `u` | Browse an individual saved channel |
|
||||
| `U` (Shift-U) | Open the selected video's channel |
|
||||
| `L` (Shift-L, in a channel view) | Browse that channel's public playlists |
|
||||
| `s` | Edit the selected channel's tags |
|
||||
| `S` | Edit tags for all saved channels in the current category |
|
||||
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
|
||||
@@ -223,6 +262,12 @@ Combined feeds are not individual categories: `S`, `R`, and `Alt-w` / `Alt-v` re
|
||||
|
||||
Press **`U` (Shift-U)** on a video to browse its creator directly, including channels you have not saved. This replaces the current temporary browsing tab without adding a subscription. Channel IDs are preferred; a valid handle is used when an ID is unavailable. Missing channel metadata leaves the current view unchanged. Unsaved channel views survive configuration changes for the session; explicitly removing a saved subscription still removes its channel view.
|
||||
|
||||
### Channel playlists (`L`)
|
||||
|
||||
Open a channel with `u` or `U`, then press **`L` (Shift-L)** in normal mode. Its public playlists load into a searchable selector: `j` / `k` or arrows move, `i` filters by title, and Enter opens the selected playlist. In filter mode, arrows or Ctrl-J / Ctrl-K move; Esc returns to normal mode, then closes. Closing cancels an in-flight playlist lookup; close and reopen to retry a failed lookup. Configure the shortcut with `channel.playlists`.
|
||||
|
||||
Playlist videos use the ordinary video table, 60-row pagination, playback checks, previews, and collection controls. They retain playlist order rather than sorting newest-first; repeated video IDs appear once, like other video views. Playlist views share the temporary tab without changing subscriptions. `-` returns to the channel and `+` restores the playlist's cached rows and selection. `r` refreshes the playlist from page one.
|
||||
|
||||
### Channel tags (`s`, or `S` for bulk editing)
|
||||
|
||||
When search results omit a channel handle, `s` reuses its saved mapping or looks up the handle from the channel's canonical ID before opening the editor. Esc cancels a pending lookup without saving. Failed lookups or mismatched channel metadata leave the config unchanged.
|
||||
@@ -287,7 +332,7 @@ Bulk editing refuses to clear all tags when a target has no `[channels]` registr
|
||||
|
||||
## Watch later
|
||||
|
||||
Watch later is **one stored collection**, deduplicated by YouTube video ID or stable Miruro episode identity, with provider-specific views. In YouTube, category lists are derived from 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`. Its combined list contains only YouTube videos. Miruro uses the flat episode lists described above. No category memberships are stored on individual videos.
|
||||
Watch later is **one stored collection**, deduplicated by YouTube video ID or exact provider-qualified AniList/Miruro episode identity, with separate provider views. YouTube category lists still derive from channel identities and current subscription categories; anime uses flat provider-local episode lists. No category memberships are stored on individual videos.
|
||||
|
||||
In YouTube, `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.
|
||||
|
||||
@@ -318,6 +363,8 @@ State writes are atomic and owner-only. Invalid state files or detected external
|
||||
|
||||
Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. Session directories are `0700`; snapshots, identity markers and Terebi-created reports are owner-only. New Miruro launches contain no resolved-source documents or CDN credentials. Existing sessions from earlier versions remain readable and are not automatically removed. Sessions are retained because CLI exit does not prove the independent GUI has finished; up to 4,096 are monitored. Close the corresponding players before removing their inactive session directories, then restart Tubular. Never remove an active player's directory.
|
||||
|
||||
Progress polling keeps byte offsets and partial records in memory, opening and closing each report for each poll. Historical sessions do not retain open report descriptors; late writes from running players are still consumed.
|
||||
|
||||
## Saved videos
|
||||
|
||||
Saved videos is a separate stored collection from watch later, deduplicated by YouTube video ID or stable Miruro episode identity, with provider-specific views. It uses offline saved metadata; YouTube entries follow **current channel category memberships**, not categories stored on individual videos. Its All categories (combined) list contains only YouTube videos; Miruro opens a flat saved-episode list.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# AniList catalog and direct streaming feasibility
|
||||
|
||||
Observed **2026-10-25**. Research only: no application/player implementation, browser automation, GUI playback, protection bypass, remote mutation, or persisted stream URLs. This investigation follows the proposed AniList catalog → independently resolved episode path, not another Miruro mirror change.
|
||||
|
||||
## Decision
|
||||
|
||||
**The catalog-to-native-media path works for a sample without Miruro.** AniList's native GraphQL interface returned catalog metadata, and ZokoAnime accepted both the returned MAL ID and its working AniList-ID route. Fresh HLS and English subtitles were retrieved; FFmpeg decoded a video frame at the start and after seeking to ten minutes.
|
||||
|
||||
**This is not yet a drop-in Terebi integration.** The sampled playlist crosses CDN origins that Terebi deliberately rejects, and no authoritative ZokoAnime episode-inventory interface was found in the inspected documentation/selector. Do not replace the browsing UI, synthesize available episodes from AniList totals, or disable the player's origin checks on the strength of this sample.
|
||||
|
||||
Evidence labels: **Observed** = executed bounded native requests/media probes; **Source** = inspected first-party documentation/JavaScript or maintained extractor code; **[INFERENCE]** = recommendation or implication not exercised in Tubular/Terebi.
|
||||
|
||||
## Discovery and exact identity mapping
|
||||
|
||||
**Source:** current [ani-cli master][ani-cli] identifies itself as version 5.1.6 and uses `hianime.at`, not the historical AllAnime interface. Its search/episode/server flow selects a ZokoAnime embed and reads its static source configuration. This is extractor-source evidence, not a recommendation to execute ani-cli or use its browser User-Agent/cipher configuration.
|
||||
|
||||
**Observed:** default native `ureq` search for Cowboy Bebop returned separate series, movie and special entries. Selecting the exact TV-series entry yielded 26 actual episode links. Episode 1's declared sub server pointed to:
|
||||
|
||||
```text
|
||||
https://zokoanime.video/stream/mal/1/1/sub
|
||||
```
|
||||
|
||||
The [ZokoAnime site][zoko] and [documentation][zoko-docs] advertise MAL/AniList-ID embeds. Their deployed [selector implementation][selector] emits source values `mal` and **`ani`**, whereas the [documentation implementation][docs-js] describes `anilist`.
|
||||
|
||||
**Observed discrepancy:** `/stream/anilist/1/1/sub` returned HTTP 404 JSON, `Route GET:/stream/anilist/1/1/sub not found`. `/stream/ani/1/1/sub` returned a playable configuration. Use the demonstrated `ani` route or AniList's `idMal`, not the prose's broken `anilist` route.
|
||||
|
||||
AniList's [media interface][media] exposes both identifiers. The exact first catalog query was:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
Media(id: 1, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title { romaji english }
|
||||
format
|
||||
status
|
||||
episodes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Observed:** unauthenticated POST `https://graphql.anilist.co` returned HTTP 200 `application/json`, with AniList ID 1, MAL ID 1, title Cowboy Bebop, format TV, status FINISHED and total 26. After this query, the direct ZokoAnime lookup used only the returned identifier; no Miruro or HiAnime request participated in that lookup.
|
||||
|
||||
A second native GraphQL query established the split-season and movie identifiers below. Route success means a source configuration was returned, not that every episode or the semantic content was visually verified.
|
||||
|
||||
| Catalog entry | AniList ID | MAL ID | Catalog total | Direct lookup observed |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Cowboy Bebop | 1 | 1 | 26 | `ani` and `mal`, episode 1: source + English subtitle; `ani`, episode 26: configuration present |
|
||||
| Attack on Titan Season 3 Part 2 | 104578 | 38524 | 10 | `ani`, episode 1: source + English/Spanish/Portuguese subtitles |
|
||||
| Cowboy Bebop: The Movie | 5 | 5 | 1 | `ani`, episode 1: configuration present |
|
||||
|
||||
**[INFERENCE]:** no external mapping dataset or title matching is needed for these routes. [Fribb/anime-lists][fribb] offers catalog-ID and TVDB/TMDB season/offset mappings, but it is unnecessary for this demonstrated direct-ID path and does not establish ZokoAnime's episode availability. No dataset was downloaded/adopted or its license relied on.
|
||||
|
||||
## Native request and media evidence
|
||||
|
||||
The temporary Rust probe used the project's existing `ureq 3.4.2`/rustls transport, normal certificate verification, default User-Agent, a 20-second global timeout, zero automatic redirects and a 2 MiB text-body limit. No cookies, authentication, browser impersonation, cipher tuning, challenge solution or proxy was supplied. Resource requests used `Referer: https://zokoanime.video/`; whether that header is mandatory was not tested.
|
||||
|
||||
Bodies were captured in memory; only metadata and redacted summaries were surfaced. Player JavaScript was inspected, never executed. The embed's `window.__P` configuration uses the Base64/XOR JSON transform explicitly implemented in [its own decoder][obfuscate]; no DRM, challenge or ad-verification protocol was implemented. Advertising scripts and view/telemetry actions were not requested.
|
||||
|
||||
| Resource | HTTP | Content-Type | Body bytes / result |
|
||||
| --- | ---: | --- | --- |
|
||||
| [HiAnime title search][search] | 200 | text/html | 250238 bytes; series/movie/special candidates |
|
||||
| [Selected series][series] | 200 | text/html | 186932 bytes |
|
||||
| [Actual episode list][episodes] | 200 | application/json | 25926 bytes; 26 links; episode 1 ID 21418, title Asteroid Blues |
|
||||
| [Episode 1 servers][servers] | 200 | application/json | 1936 bytes; ZokoAnime and HD-1 for sub/dub |
|
||||
| [Direct MAL episode embed][mal-embed] | 200 | text/html | 1832 bytes; source + English subtitle configuration |
|
||||
| [Direct AniList episode embed][ani-embed] | 200 | text/html | 1832 bytes; source + English subtitle configuration |
|
||||
| Fresh HLS master, URL redacted | 200 | application/vnd.apple.mpegurl | 98 bytes; one advertised variant |
|
||||
| HLS variant, URL redacted | 200 | application/vnd.apple.mpegurl | 38193 bytes; 353 segments; ENDLIST; no EXT-X-KEY tags |
|
||||
| English subtitle, URL redacted | 200 | text/vtt | 14466 bytes; WEBVTT; 217 cue arrows |
|
||||
|
||||
**Observed native media:** `ffprobe 9.0.2` exited 0 and reported H.264 video at 1448×1080, AAC audio at 48000 Hz, and duration 1483.145982 seconds. The manifest advertised 1920×1080; the decoded stream dimensions are the stronger evidence. No audio track/language equivalence across other items was verified.
|
||||
|
||||
Two bounded `ffmpeg` runs, each capped at 30 seconds, requested one video frame with input seeks of 0 and 600 seconds. Both exited 0 with `frame=1` and `progress=end`. Final `ffprobe` and decode checks explicitly set `tls_verify=1` and `max_redirects=0`, used `http,https,tcp,tls` protocols only, five-second socket timeouts, 1 MiB probe size, two-second analysis duration, and `extension_picky=0`. The latter permits segment-content probing rather than trusting CDN filename extensions; Terebi already uses the same setting. No whole-episode download or GUI playback occurred.
|
||||
|
||||
### Missing episodes are not HTTP errors
|
||||
|
||||
**Observed:** [episode 27][missing] of the finished 26-episode sample returned **HTTP 200 `text/html`**, but no `window.__P` configuration. Its visible page said “Error 404 / Not found” and that there was nothing to play. Status/MIME alone cannot establish availability. The resolver must recognize missing configuration as unavailable, not a successful empty source.
|
||||
|
||||
**Source:** AniList's `episodes` field is the amount when complete; its [streamingEpisodes][streaming-episodes] links are legal streaming-site links, not a general native-stream inventory. The inspected ZokoAnime documentation exposes a numbered embed lookup, and the player selector takes a manually entered episode number. No authoritative episode-list interface was established. The HiAnime listing proves the sample's inventory but would add another aggregation dependency if used in production.
|
||||
|
||||
**[INFERENCE]:** episodic browsing needs a separate availability decision: obtain a supported inventory, or explicitly distinguish catalog episode numbers from provider-confirmed playable episodes and resolve on demand. This report does not choose or silently change Tubular's current available-episodes-only behavior. Split seasons, movies and specials still need explicit mapping semantics; the sampled routes are not universal proof.
|
||||
|
||||
## Terebi compatibility and security
|
||||
|
||||
**Observed:** the master and variant were on `hls.dramahot.top`, while their segment URLs named `hls1.drama1.cfd`. URLs/query tokens are deliberately not retained here.
|
||||
|
||||
**Source:** current [`resolvedIOOpen`][player-policy] rejects any resource whose origin differs from the initial source's origin, and rejects redirects before forwarding custom headers. [`StreamURL.parse`][url-parse] has a dedicated Miruro case only; other hosts enter the YouTube path, not a ZokoAnime resolver. Existing Miruro identity/progress/state must not be reused to disguise this provider.
|
||||
|
||||
**[INFERENCE]:** the sampled manifest would fail the current protected HLS path despite generic FFmpeg being able to decode it. Native FFmpeg success is not Terebi acceptance, subtitle rendering, resume/progress, or complete playback proof. No Terebi source or security policy was changed or exercised with this provider.
|
||||
|
||||
A future integration needs a separately reviewed, origin-scoped resource/header policy for the concrete CDN relationships, or a demonstrably compatible source. Do not simply remove the origin comparison, globally allow cross-origin redirects, or forward headers to arbitrary manifest hosts.
|
||||
|
||||
## Recommendation and remaining prerequisites
|
||||
|
||||
- **Go for a focused implementation design:** AniList catalog → exact AniList/MAL ID → ZokoAnime native resolver is demonstrated independently of Miruro. Keep catalog/selection in Tubular and fresh source/subtitle resolution in Terebi. Do not add a general multi-provider framework or mapping database for this one path.
|
||||
- **Before replacing browsing:** settle authoritative episode availability versus explicitly labeled on-demand resolution. Keep unknown/missing distinct from zero and from network/protection errors.
|
||||
- **Before native player integration:** decide the narrow cross-origin policy and provider-correct identity/progress contract; map or explicitly retain existing Miruro-keyed collections rather than reinterpreting them.
|
||||
- **Before distribution:** review [AniList's terms][terms]. They prohibit mass collection, API use as backup/storage, and competing non-complementary list/tracker services. Tubular's local watched-state makes applicability worth clarifying; this investigation does not establish permission. ZokoAnime advertises iframe integration, but no stable documented raw-media/native interface or distribution-rights guarantee was established.
|
||||
- **Known limits:** small point-in-time sample; no ongoing-series inventory, dub media decode, special-numbering coverage, full episode playback, source expiry/IP-binding, uptime, geographical availability or rights verification. ZokoAnime may itself have upstream dependencies; only independence from Miruro on the exercised path is proven.
|
||||
|
||||
### Reproduction record
|
||||
|
||||
The executed temporary harness was built with `cargo build --locked --example direct_provider_probe` and called as `target/debug/examples/direct_provider_probe URL`; AniList POST payloads and resource Referer values were passed through environment variables. A Python subprocess driver kept response bodies and fresh URLs in memory, applied the first-party static config decoder and invoked `ffprobe`/`ffmpeg` directly. The temporary Rust source, binary and driver state were removed after this report; that Cargo example is **not a retained runnable command**. No permanent tests were added for research-only work.
|
||||
|
||||
For a fresh ordinary-HTTPS metadata/status check, the following requests reproduce the public request shapes; the native results above were obtained with ureq, not these curl commands:
|
||||
|
||||
```sh
|
||||
curl --silent --show-error --max-time 20 \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"query":"query { Media(id: 1, type: ANIME) { id idMal title { romaji } episodes } }"}' \
|
||||
https://graphql.anilist.co
|
||||
|
||||
curl --silent --show-error --max-time 20 --output /dev/null \
|
||||
--write-out 'HTTP %{http_code}; %{content_type}\n' \
|
||||
https://zokoanime.video/stream/ani/1/1/sub
|
||||
```
|
||||
|
||||
Re-run source resolution before any media check; never save or publish resolved tokenized URLs. The first-party configuration decoder and native media flags above document the exercised method without retaining credentials or stale media URLs.
|
||||
|
||||
[ani-cli]: https://raw.githubusercontent.com/pystardust/ani-cli/master/ani-cli
|
||||
[zoko]: https://zokoanime.video/
|
||||
[zoko-docs]: https://zokoanime.video/docs
|
||||
[selector]: https://zokoanime.video/assets/player-CiFNLeyH.js
|
||||
[docs-js]: https://zokoanime.video/assets/docs-sIsMGsz7.js
|
||||
[obfuscate]: https://zokoanime.video/core/obfuscate.js?v=23
|
||||
[media]: https://docs.anilist.co/reference/object/media
|
||||
[terms]: https://docs.anilist.co/guide/terms-of-use
|
||||
[streaming-episodes]: https://docs.anilist.co/reference/object/mediastreamingepisode
|
||||
[fribb]: https://github.com/Fribb/anime-lists
|
||||
[search]: https://hianime.at/search?keyword=Cowboy+Bebop
|
||||
[series]: https://hianime.at/cowboy-bebop-1281
|
||||
[episodes]: https://hianime.at/api/theme/episode/list/1281
|
||||
[servers]: https://hianime.at/api/theme/episode/servers?episodeId=21418
|
||||
[mal-embed]: https://zokoanime.video/stream/mal/1/1/sub
|
||||
[ani-embed]: https://zokoanime.video/stream/ani/1/1/sub
|
||||
[missing]: https://zokoanime.video/stream/ani/1/27/sub
|
||||
[player-policy]: ../../terebi/Sources/Core/player.cpp#L111-L126
|
||||
[url-parse]: ../../terebi/Sources/App/StreamResolver.swift#L8-L35
|
||||
+6
-2
@@ -2,13 +2,15 @@
|
||||
|
||||
Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**; ownership split updated **2026-10-22**. These are reverse-engineering notes, not an official API specification. Tubular now owns catalog/detail retrieval and stable identity; Terebi resolves episode URLs. Source/CDN investigations below are historical evidence, not the current launch path.
|
||||
|
||||
**Mirror update (2026-10-25):** the [official directory](https://www.miruro.com/#domains) links Miruro 2.0 at `https://miruro.cx`, `https://miruro.pw`, `https://miruro.ws`, `https://miruro.to`, `https://miruro.tv`, and `https://miruro.bz`; `www.` hosts are labeled Legacy. Tubular now uses these six explicit origins for catalog/detail requests. Local native `ureq` and curl probes received **403 `text/html`** from all six autocomplete endpoints; curl returned Cloudflare block pages. Thus live schema/ID compatibility and successful browsing on these mirrors remain unverified. The original `cargo run --locked --example miruro_probe` now exercises all six and reports `Miruro request failed on 6/6 mirrors`, rather than stopping at the obsolete browsing origin. Endpoint/schema evidence below remains the historical snapshot, not a claim of current mirror access.
|
||||
|
||||
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.
|
||||
**Transport distinction (historical):** 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 was proven for that client and sample, not every transport, network, provider or future request. Inspect HTTP status and Content-Type before decoding; never treat protection pages as empty results. The current client can try the next explicitly listed official mirror, but cannot restore access when every mirror blocks it.
|
||||
|
||||
## Endpoint map
|
||||
|
||||
@@ -395,7 +397,7 @@ Current library interface:
|
||||
|
||||
| Call | Result / contract |
|
||||
| --- | --- |
|
||||
| `Client::default()` | Reusable blocking HTTP agent; 25-second global request timeout; no automatic redirects |
|
||||
| `Client::default()` | Reusable blocking HTTP agent; six official 2.0 mirrors; sticky successful origin; 25-second total request budget, at most five seconds per attempt; 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 |
|
||||
| `Identity::new(id, episode)`, `Identity::from_key(key)` | Validated catalog namespace/episode identity; `key()` is `miruro:<opaque ID>:<episode>` |
|
||||
@@ -403,6 +405,8 @@ Current library interface:
|
||||
|
||||
Raw catalog methods preserve JSON metadata without a second deployment-specific schema. IDs/slugs are validated path segments; HTTP/HTML/Content-Type failures remain errors, not empty results. Successful bodies are limited to 2 MiB. Source selection, resource headers and subtitles belong to Terebi.
|
||||
|
||||
The client starts at `miruro.cx` and remembers the last origin whose response passed HTTP, Content-Type and bounded-body checks. On transport/body failures, unexpected Content-Type, HTTP 301–308, 403, 404, 408 or 5xx, it tries the remaining origins once in directory order. Other HTTP errors, including 400, 401 and 429, stop immediately; rotating mirrors must not circumvent rate limits. Exhaustion reports the attempted/total mirror count and last origin/error. JSON/route decoding and detail-ID validation remain mandatory and do not trigger additional fallback. The directory is not scraped at runtime, and requests are not randomized. Playback URLs deliberately retain `barelystarted.miruro.tv`: current Terebi admits and resolves only that hostname, so playback failover requires a separate player change.
|
||||
|
||||
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.
|
||||
|
||||
The retained `cargo run --locked --example miruro_probe` smoke now covers search → detail/episodes only, not sources or playback.
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Terebi episode-URL playback handoff
|
||||
|
||||
Approved ownership split, 2026-10-22. This document hands work to the Terebi team; it does not claim Terebi implementation or visible-player proof. Tubular cutover checks are recorded below. Primary metadata evidence, sampled intervals, frontend normalization and investigation limits are in [Miruro segment research](miruro-segments.md). Earlier source/CDN evidence is retained in [Miruro API research](miruro-api.md).
|
||||
|
||||
## Ownership and current support
|
||||
|
||||
- **Tubular:** catalog/detail browsing, selecting actual returned episodes, collections, reporting-session identity and watched decisions.
|
||||
- **Terebi:** episode-URL parsing, fresh source resolution, headers, subtitles, playback title/metadata, resume state and intro/outro visualization.
|
||||
|
||||
Terebi already accepts native Miruro episode URLs, resolves sources and supports stable version-2 episode progress. Its current title is `Miruro episode N`; enriching that title and displaying intro/outro intervals remain work for Terebi. A successful URL launch is not proof of either feature. Direct Terebi URLs and Tubular launches must use the same resolver and metadata lifecycle; do not add a caller-specific source-file fallback.
|
||||
|
||||
## URL and canonical-route handling
|
||||
|
||||
Tubular uses `PlayerLaunch::start(&videos, player, &mut state, enqueue)` for both providers; the launcher allocates and binds private reporting sessions through `State`. Plain Enter passes `false`, retaining a separate player; `video.enqueue` passes `true`. For Miruro, `Identity::from_key(id)?.watch_url()?` validates the stable identity and constructs:
|
||||
|
||||
```sh
|
||||
terebi --new-instance \
|
||||
--url 'https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=RETURNED_EPISODE' \
|
||||
--progress-file '/private/session/progress.jsonl'
|
||||
```
|
||||
|
||||
`episode` is a deliberate non-identifying placeholder slug. The existing Terebi `StreamURL.parse` accepts the slug and discards it, retaining opaque ID and episode. Never parse Tubular's combined display title to obtain a slug, identity or episode number. Use the returned episode number, not `absolute_number`, external AniList/MAL ID, array index or source-provider identifier.
|
||||
|
||||
**Observed read-only route probe, 2026-10-22:** `https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode/__data.json?ep=1` returned HTTP 200 `application/json` with a redirect envelope to `/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1`. This is route-data canonicalization, not necessarily an HTTP redirect. Terebi metadata acquisition must recognize the envelope, safely validate the destination against the expected Miruro origin, opaque ID and selected episode, and request the canonical route's `__data.json` while preserving `ep`. Bound redirect handling and reject loops or identity/origin changes; do not mistake HTTP 200 for decoded metadata success. Canonical watch data can be newline-delimited `text/sveltekit-data`, not just one JSON object. Decode response-local value tables and promised chunks as documented in the evidence.
|
||||
|
||||
A failed title/segment lookup or unsafe/unusable metadata redirect must not prevent otherwise resolvable ordinary playback. Retain a usable generic title when enrichment is unavailable; never change media identity to make metadata fit.
|
||||
|
||||
## Stable identity, sessions and progress remain unchanged
|
||||
|
||||
The collection key remains `miruro:<opaque ID>:<canonical episode>`. Runtime/report identity remains `{provider:"miruro", media_id:<opaque ID>, episode:<returned episode>}`. Titles, slugs and expiring CDN URLs are not identity. Metadata title enrichment must not create a new resume key or split an episode's history.
|
||||
|
||||
Tubular creates a private reporting session and registers its expected identity before launching. Terebi exclusively creates and appends the private progress report; Tubular consumes it independently of launcher lifetime. Existing retained sessions and collections, including old snapshots and reports recovered after restart, must remain readable. Do not migrate collections or add persistent `Video` fields to transport metadata. Existing session retention, explicit inactive-session cleanup and the 4,096-session cap remain in force; launcher exit, a final event or Tubular shutdown does not imply the independent player has stopped.
|
||||
|
||||
Progress-v2 is unchanged, for example:
|
||||
|
||||
```json
|
||||
{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"opaque_ID","episode":1},"position_seconds":95.0,"duration_seconds":100.0,"completion_eligible":true}
|
||||
```
|
||||
|
||||
Tubular checks the complete identity against the launch session. Acknowledged positive positions admit currently-watching entries. Watched completion remains `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`; null duration is ineligible. Terebi owns resume positions. Existing partial-line/restart handling stays intact. YouTube canonical URLs, progress-v1 compatibility and watched policy are unchanged.
|
||||
|
||||
## Terebi metadata and timeline work
|
||||
|
||||
Acquire title and optional episode-bound intervals in the native URL resolver, not from `/api/sources` alone: the sampled sources response has no intervals. Watch `episodesData.aniskip` and detail `skip_times` contain equivalent sampled timings with different field names; use the existing route metadata surface rather than inventing another lookup service. Preserve episode binding and fractional seconds. Obtain a descriptive series/episode title from returned metadata, not a guessed title derived from the placeholder slug.
|
||||
|
||||
Requested visualization is **labeled opening/ending bands in the controls progress bar**, leaving the playhead and played/buffered indicators readable; do not distinguish the bands by color alone. No auto-skip, skip buttons or new seek behavior is requested. Normalize only evidenced kinds: `op`/`mixed-op` → intro, `ed`/`mixed-ed` → outro. Do not relabel `recap` or unknown kinds, invent absent intervals, or decompose mixed kinds. Raw unknown kinds may remain metadata but do not become intro/outro bands.
|
||||
|
||||
Validate finite start/end, `start >= 0`, `end > start`, selected episode match and `end <= actual media duration` (equality allowed). Wait for positive player-reported duration, not catalog duration; revalidate on duration changes. Invalid/out-of-bounds intervals should not be scaled, shifted or trimmed into plausible bands. Do not invent absent provenance, votes or reference-length fields; the frontend's optional checks and coverage/alignment limits are recorded in the primary evidence. In-bounds timings alone do not establish synchronization across different providers/cuts.
|
||||
|
||||
Start each media lifecycle with no intervals. Clear title/interval metadata on media replacement, bind asynchronous results to the current stable identity and reject stale responses, including results arriving after a different episode starts. Missing, empty, malformed or unavailable optional metadata leaves ordinary playback usable and the progress bar without those bands. No source-v2 contract, `--title` option, segment file, caller distinction or Tubular source projection is needed.
|
||||
|
||||
## Acceptance scenarios for the Terebi implementation
|
||||
|
||||
1. **Series selection:** launch a returned episode from Tubular's series screen using the placeholder URL; source resolution, headers/subtitles and canonical-route metadata use the native resolver. Compare a direct Terebi launch of the same URL: same descriptive title, identity, resume position and matching progress-v2 records.
|
||||
2. **Offline collections:** launch the same identity from saved, watch-later and currently-watching collections without first opening its series. Include an old snapshot with no slug, new metadata fields or intervals. All paths resolve fresh sources and acquire the same title/optional intervals without migration; offline collection browsing itself does not require network availability. Playback still requires resolvable resources.
|
||||
3. **Identity continuity:** canonical slug/title changes do not fork resume/progress state; wrong-episode/provider/media records cannot admit or complete another episode. Existing retained reports remain consumable after Tubular restarts; the unchanged 95% rule applies.
|
||||
4. **Optional metadata:** absent/empty metadata, malformed intervals, unknown kinds and failed metadata requests leave normal playback usable. Unknown kinds are not intro/outro. A valid sampled opening/ending appears as bands only, with no automatic seeking or added skip buttons.
|
||||
5. **Bounds and replacement:** with controlled metadata and player duration, reject negative/nonfinite/reversed/out-of-bounds or wrong-episode intervals; allow an end exactly at duration. Revalidate on actual-duration changes. Replace media while metadata is pending and confirm no old title/bands leak into the replacement; absent metadata clears earlier bands.
|
||||
6. **Canonicalization:** exercise the placeholder's 200 redirect envelope and canonical streamed route response. Unsafe origin/identity changes, loops or unavailable metadata fail only optional enrichment, not an independently resolvable source.
|
||||
7. **YouTube regression:** canonical YouTube URL launch, independent player lifetime, existing title/resume handling, progress-v1 ingestion and watched decisions remain unchanged.
|
||||
|
||||
These are acceptance requirements, not reported passes. Terebi implementation and visible progress-bar/title confirmation remain the receiving team's responsibility; no Terebi UI or browser verification was performed.
|
||||
|
||||
## Tubular cutover verification
|
||||
|
||||
Exercised locally on 2026-10-22:
|
||||
|
||||
- `cargo fmt --all --check`, `cargo test --locked` (**220 passed**), `cargo build --locked`, and the built executable's `--help` passed.
|
||||
- `cargo run --locked --example miruro_probe` returned eight catalog records, three episodes for the documented title, and an episode-1 URL. The example makes no source/CDN requests.
|
||||
- The actual terminal application launched a live catalog episode and a legacy snapshot from saved, watch-later and currently-watching views through `--new-instance --url ... --progress-file ...`. A capture/report launcher fixture confirmed matching stable identities, no `source.json`, private session/report modes, positive v2 watching admission, rejection of a wrong-episode completion, and matching completion recovered after restart. YouTube's actual terminal launch retained its canonical URL and v1 progress admission.
|
||||
- `cargo clippy --locked --all-targets -- -D warnings` is blocked by findings in untouched files: `src/youtube/catalog.rs:184` (`collapsible_if`) and `src/titles.rs:492` (`reversed_empty_ranges`).
|
||||
|
||||
The launcher fixture did not run Terebi. Native Terebi playback, title enrichment and visible timeline bands are not verified by these Tubular checks. Temporary smoke fixtures were removed; the user-facing catalog probe remains.
|
||||
|
||||
## Ordered stream queue handoff
|
||||
|
||||
Multiple-video selection extends the URL handoff without transporting resolved sources or changing progress records. Tubular sends checked rows in displayed order; it does not combine providers' browsing sessions into one selection. Terebi accepts either provider in a queue and preserves the supplied order instead of applying local-file filename sorting.
|
||||
|
||||
```sh
|
||||
terebi --new-instance --queue-file '/private/session/queue.json'
|
||||
```
|
||||
|
||||
The version-1 manifest is:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"items": [
|
||||
{
|
||||
"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"progress_file": "/private/playback-sessions/session-A/progress.jsonl"
|
||||
},
|
||||
{
|
||||
"url": "https://barelystarted.miruro.tv/watch/opaque_Show-1/episode?ep=3",
|
||||
"progress_file": "/private/playback-sessions/session-B/progress.jsonl"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The manifest contains 1–4,096 entries. Plain Enter uses it for batches of two or more; a single item retains `--new-instance --url` and `--progress-file`. Enqueue uses a manifest even for one item. `--queue-file` requires either `--new-instance` or `--enqueue`, an absolute manifest path, and no other media argument or global `--progress-file`; those two launch modes cannot be combined. Terebi validates the entire manifest before delivery and again in the receiving application: supported page URLs, distinct stable identities, distinct absolute new report destinations, and plain owner-only report directories. POSIX-canonical report paths deduplicate macOS `/var` and `/private/var` aliases. Invalid batches fail rather than silently playing only their first entry.
|
||||
|
||||
Tubular validates all identities, snapshots and available session capacity before allocating a batch. It creates one existing-format snapshot/reporting session and identity marker per item; it never creates Terebi's report files. The manifest is `0600` inside the first retained `0700` session directory and contains no source URLs or transport credentials. Retention and the shared 4,096-session cap are unchanged; inactive queue entries count toward the cap too.
|
||||
|
||||
Terebi resolves only the selected queue item, through its ordinary YouTube/Miruro resolver and metadata lifecycle. Next/previous, sidebar selection, reordering/removal and natural-end advancement reuse the existing queue. Switching entries cancels stale resolution/metadata work without discarding the queue; failed playback does not mark an item watched. Local-file queues retain natural filename ordering. A submitted batch opens one independent player, not one window per checked item.
|
||||
|
||||
Each selected entry reports only to its own registered destination. Switching drains the previous writer; returning to an entry appends only to the original owned regular-file inode, never truncating or adopting an unrelated existing file. Resume keys and version-1 YouTube/version-2 Miruro identities remain stable. Tubular's watching admission, 95% completion rule, manual-status precedence and restart recovery consume these independent reports unchanged.
|
||||
|
||||
### Queue verification status
|
||||
|
||||
- Tubular: `cargo fmt --all --check`, `cargo test --locked` (**230 passed**), `cargo build --locked` and the built CLI's `--help` passed.
|
||||
- Both actual terminal interfaces were exercised with offline saved rows and a capture launcher: checking in reverse order produced a private two-item manifest in displayed order; successful submission cleared checks and the next Enter used single-video URL playback. This fixture did not run Terebi.
|
||||
- Terebi: `make check-cli check-queue check-reporting check-resolved all` passed. Headless native playback exercised mixed YouTube/Miruro queues, stale-result cancellation, manual navigation, natural-end advancement, fresh resolution/resume on revisits and isolated per-item reports.
|
||||
- The packaged GUI smoke did not complete: native state snapshots stalled. Temporary tracing and probe files were removed. Further UI testing was stopped at the user's request and remains user-owned; no complete GUI smoke or visual-layout pass is claimed. The TODO remains open for that confirmation.
|
||||
|
||||
## Append to a running player
|
||||
|
||||
```sh
|
||||
terebi --enqueue --queue-file '/private/session/queue.json'
|
||||
```
|
||||
|
||||
`video.enqueue` defaults to Ctrl+Return. It shares plain Enter's checked-display-order selection and hovered-row fallback in video/episode lists, previews and offline collections. Miruro catalog/show rows and text inputs do not enqueue. Plain Enter remains a new-instance launch.
|
||||
|
||||
The CLI delivers a manifest reference through an explicit-app native URL-open event, so later submissions reach an already running player rather than relying on startup arguments. The receiver appends in supplied order without restarting current playback, changing paused/playing intent, cancelling current resolution or resolving appended items early. An empty or newly launched player starts the first queued item. OS-level delivery to a running packaged app still needs user confirmation; headless receiver checks are not LaunchServices proof.
|
||||
|
||||
The complete request is rejected on an identity already in the current queue, a retained report identity, a retained report path, or the original single-video report identity. Removing a row does not release its registration. Registrations are atomic; failed batches do not reserve their valid prefix. The original single-video report remains independently navigable, and unregistered identities cannot leak into it once queue reporting is active. Errors after OS handoff appear in Terebi; Tubular's checks retain the existing clear-on-successful-launcher-spawn policy.
|
||||
|
||||
### Enqueue verification
|
||||
|
||||
- Tubular: `cargo fmt --all`, `cargo test --locked` (**233 passed**), `cargo build --locked` and built `--help`; Rust launcher diagnostics were clean.
|
||||
- Actual YouTube and Miruro terminal interfaces with injected modified-key sequences: reverse-checked rows produced display-ordered enqueue manifests, cleared checks fell back to the hovered row, Ctrl/Super+Return and an F12 override worked, and plain Return still used `--new-instance --url`. Six handoffs used private `0600` manifests in `0700` session directories with distinct report destinations. The capture launcher did not run Terebi, and injecting already-encoded modified keys did not verify terminal protocol negotiation.
|
||||
- Terebi: `check-cli`, `check-queue`, `check-reporting` and `make all` passed. A regression check first failed on rebinding the original single-video identity and passed after binding it independently of appended reports.
|
||||
- Headless native enqueue smoke with local video-only HLS passed: repeated delivery while paused/playing, unchanged current selection/revision, lazy mixed-provider append, natural-end advance, navigation/revisits, atomic rejection/rollback, isolated original/appended reports and empty-player startup.
|
||||
- Full `make check-resolved` stopped in the pre-existing mixed-queue natural-end scenario at `Could not start Core Audio (560227702)` before reaching enqueue. No full A/V-suite pass is claimed. No packaged-GUI/LaunchServices smoke or visual-layout confirmation was performed; the app was built, not installed. Temporary terminal/server fixtures were removed.
|
||||
- Modified-Return correction: a protocol-aware PTY driver reproduced Ctrl+Return opening a new instance when startup did not request disambiguation. After adding a session-scoped Kitty keyboard protocol push/pop, the same actual TUI driver observed Ctrl+Return using `--enqueue`, plain Return using `--new-instance`, and keyboard-mode restoration on exit. `cargo test --locked` remained **233 passed** and the build passed. This emulates a supporting terminal; it does not prove support in the user's terminal/multiplexer. Legacy identical Return bytes require a different binding.
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Read-only native smoke: cargo run --locked --example anilist_probe
|
||||
use std::io;
|
||||
use tubular::{
|
||||
anilist::{Client, catalog_count},
|
||||
anime::Identity,
|
||||
};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let client = Client::default();
|
||||
let results = client.search("Cowboy Bebop")?;
|
||||
let media = results
|
||||
.iter()
|
||||
.find(|media| media["id"] == "1")
|
||||
.ok_or_else(|| io::Error::other("Exact Cowboy Bebop AniList ID 1 absent from search"))?;
|
||||
let id = media["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| io::Error::other("Missing catalog ID"))?;
|
||||
let detail = client.detail(id)?;
|
||||
let count = catalog_count(&detail)?.ok_or_else(|| io::Error::other("Catalog count unknown"))?;
|
||||
if count == 0 {
|
||||
return Err(io::Error::other("No catalog episode number 1"));
|
||||
}
|
||||
let identity = Identity::anilist(id.into(), 1)?;
|
||||
println!(
|
||||
"native search: {} records; exact AniList ID {id}",
|
||||
results.len()
|
||||
);
|
||||
println!(
|
||||
"detail: {count} catalog episode numbers; selected episode 1; source not checked by this probe"
|
||||
);
|
||||
println!("identity: {}", identity.key());
|
||||
println!("watch URL: {}", identity.watch_url()?);
|
||||
println!(
|
||||
"PASS: native AniList search -> exact detail -> catalog episode -> stable sub URL (no source check or playback request)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Exercises native catalog metadata and stable episode URLs; no playback requests.
|
||||
use std::io;
|
||||
|
||||
use tubular::miruro::{Client, Identity};
|
||||
use tubular::{anime::Identity, miruro::Client};
|
||||
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -30,7 +30,7 @@ fn main() -> io::Result<()> {
|
||||
"detail: {} returned episodes; selected episode {number}",
|
||||
detail.episodes.len()
|
||||
);
|
||||
let identity = Identity::new(id.into(), number)?;
|
||||
let identity = Identity::miruro(id.into(), number)?;
|
||||
println!("watch URL: {}", identity.watch_url()?);
|
||||
println!("PASS: native search -> detail/episodes -> stable watch URL; no playback requests");
|
||||
Ok(())
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
//! AniList metadata only. Episode numbers are catalog information, never stream availability.
|
||||
use crate::anime::positive_decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::{io, time::Duration};
|
||||
|
||||
const FIELDS: &str = "id title { english romaji native } format status episodes season seasonYear description(asHtml: false) coverImage { extraLarge large medium } bannerImage duration averageScore genres source countryOfOrigin nextAiringEpisode { episode }";
|
||||
const BODY_LIMIT: u64 = 2 * 1024 * 1024;
|
||||
|
||||
pub struct Client {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
impl Default for Client {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent: ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(20)))
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.new_agent(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Client {
|
||||
pub fn search(&self, query: &str) -> io::Result<Vec<Value>> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() || query.len() > 4096 {
|
||||
return Err(invalid("AniList query must contain 1–4096 bytes"));
|
||||
}
|
||||
let mut response = self.query(&format!("query ($search: String!) {{ Page(page: 1, perPage: 8) {{ media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {FIELDS} }} }} }}"), json!({"search": query}))?;
|
||||
let Some(Value::Array(items)) = response.pointer_mut("/Page/media").map(Value::take) else {
|
||||
return Err(invalid("AniList search is missing its media array"));
|
||||
};
|
||||
if items.len() > 8 {
|
||||
return Err(invalid("AniList search exceeded eight results"));
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let media = normalize(item)?;
|
||||
if !ids.insert(media["id"].as_str().unwrap().to_owned()) {
|
||||
return Err(invalid("AniList returned duplicate IDs"));
|
||||
}
|
||||
Ok(media)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn detail(&self, id: &str) -> io::Result<Value> {
|
||||
let id_number = positive_decimal(id)?;
|
||||
let mut response = self.query(
|
||||
&format!("query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {FIELDS} }} }}"),
|
||||
json!({"id": id_number}),
|
||||
)?;
|
||||
let media = response
|
||||
.get_mut("Media")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(Value::take)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "AniList catalog title not found")
|
||||
})?;
|
||||
let media = normalize(media)?;
|
||||
if media["id"].as_str() != Some(id) {
|
||||
return Err(invalid("AniList detail ID does not match request"));
|
||||
}
|
||||
Ok(media)
|
||||
}
|
||||
fn query(&self, query: &str, variables: Value) -> io::Result<Value> {
|
||||
let body = json!({"query": query, "variables": variables}).to_string();
|
||||
let mut response = self
|
||||
.agent
|
||||
.post("https://graphql.anilist.co")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.send(body.as_str())
|
||||
.map_err(|_| io::Error::other("AniList network request failed"))?;
|
||||
let status = response.status().as_u16();
|
||||
if status != 200 {
|
||||
return Err(io::Error::new(
|
||||
match status {
|
||||
401 | 403 => io::ErrorKind::PermissionDenied,
|
||||
404 => io::ErrorKind::NotFound,
|
||||
_ => io::ErrorKind::Other,
|
||||
},
|
||||
format!("AniList HTTP {status}"),
|
||||
));
|
||||
}
|
||||
if response.body().mime_type() != Some("application/json") {
|
||||
return Err(invalid("AniList returned non-JSON content"));
|
||||
}
|
||||
let body = crate::read_catalog_body(response.body_mut(), BODY_LIMIT)
|
||||
.map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?;
|
||||
decode(&body)
|
||||
}
|
||||
}
|
||||
fn decode(body: &str) -> io::Result<Value> {
|
||||
let mut value: Value =
|
||||
serde_json::from_str(body).map_err(|_| invalid("Malformed AniList JSON"))?;
|
||||
if let Some(errors) = value.get("errors").filter(|v| !v.is_null()) {
|
||||
let errors = errors
|
||||
.as_array()
|
||||
.ok_or_else(|| invalid("Malformed AniList GraphQL errors"))?;
|
||||
if !errors.is_empty() {
|
||||
return Err(io::Error::other(
|
||||
"AniList GraphQL request failed (catalog data not accepted)",
|
||||
));
|
||||
}
|
||||
}
|
||||
value
|
||||
.get_mut("data")
|
||||
.filter(|v| v.is_object())
|
||||
.map(Value::take)
|
||||
.ok_or_else(|| invalid("AniList response is missing data"))
|
||||
}
|
||||
/// Normalize metadata field names to the existing anime UI, retaining explicit nulls.
|
||||
fn normalize(mut media: Value) -> io::Result<Value> {
|
||||
let id = media
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.filter(|n| *n > 0 && *n <= i32::MAX as u32)
|
||||
.ok_or_else(|| invalid("Invalid AniList catalog ID"))?;
|
||||
let object = media
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| invalid("Invalid AniList media"))?;
|
||||
object.insert("id".into(), Value::String(id.to_string()));
|
||||
object.insert("catalog_provider".into(), Value::String("anilist".into()));
|
||||
for (source, target) in [
|
||||
("episodes", "episode_count"),
|
||||
("seasonYear", "season_year"),
|
||||
("duration", "episode_duration_minutes"),
|
||||
("averageScore", "average_score"),
|
||||
("countryOfOrigin", "country_of_origin"),
|
||||
] {
|
||||
let value = object.get(source).cloned().unwrap_or(Value::Null);
|
||||
object.insert(target.into(), value);
|
||||
}
|
||||
let cover = object
|
||||
.get("coverImage")
|
||||
.and_then(|v| {
|
||||
v.get("extraLarge")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| v.get("large").and_then(Value::as_str))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
object.insert(
|
||||
"cover_url".into(),
|
||||
cover.map(Value::String).unwrap_or(Value::Null),
|
||||
);
|
||||
let count = catalog_count(&media)?;
|
||||
media.as_object_mut().unwrap().insert(
|
||||
"catalog_count".into(),
|
||||
count.map(|n| json!(n)).unwrap_or(Value::Null),
|
||||
);
|
||||
Ok(media)
|
||||
}
|
||||
/// Finished totals are catalog numbers; releasing totals remain plans, not aired episodes.
|
||||
pub fn catalog_count(media: &Value) -> io::Result<Option<u32>> {
|
||||
let number = |value: Option<&Value>| -> io::Result<Option<u32>> {
|
||||
match value {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.filter(|n| *n <= i32::MAX as u32)
|
||||
.map(Some)
|
||||
.ok_or_else(|| invalid("Invalid AniList episode count")),
|
||||
}
|
||||
};
|
||||
let total = number(media.get("episode_count"))?;
|
||||
match media.get("status").and_then(Value::as_str) {
|
||||
Some("FINISHED") => Ok(total),
|
||||
Some("NOT_YET_RELEASED") => Ok(Some(0)),
|
||||
Some("RELEASING") => {
|
||||
let next = number(media.pointer("/nextAiringEpisode/episode"))?;
|
||||
if next == Some(0) {
|
||||
return Err(invalid("Invalid AniList next airing episode"));
|
||||
}
|
||||
let aired = next.map(|n| n - 1);
|
||||
if aired.zip(total).is_some_and(|(aired, total)| aired > total) {
|
||||
return Err(invalid("AniList aired count exceeds planned total"));
|
||||
}
|
||||
Ok(aired)
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn catalog_boundaries_and_graphql_errors() {
|
||||
let finished = normalize(json!({"id":1,"status":"FINISHED","episodes":26})).unwrap();
|
||||
assert_eq!(catalog_count(&finished).unwrap(), Some(26));
|
||||
let ongoing = normalize(
|
||||
json!({"id":2,"status":"RELEASING","episodes":24,"nextAiringEpisode":{"episode":5}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(catalog_count(&ongoing).unwrap(), Some(4));
|
||||
for status in ["RELEASING", "HIATUS", "CANCELLED", "FINISHED"] {
|
||||
assert_eq!(
|
||||
catalog_count(&normalize(json!({"id":2,"status":status,"episodes":null})).unwrap())
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
normalize(
|
||||
json!({"id":2,"status":"RELEASING","episodes":2,"nextAiringEpisode":{"episode":5}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(normalize(json!({"id":0})).is_err());
|
||||
assert!(decode(r#"{"data":{"Media":{"id":1}},"errors":[{"message":"bad"}]}"#).is_err());
|
||||
assert!(decode(r#"{"data":null}"#).is_err());
|
||||
assert!(decode(r#"{"errors":{},"data":{}}"#).is_err());
|
||||
assert!(decode(r#"{"data":{"Media":null}}"#).unwrap()["Media"].is_null());
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
//! Stable identities for the two explicitly separate anime catalogs.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Provider {
|
||||
AniList,
|
||||
#[default]
|
||||
Miruro,
|
||||
}
|
||||
impl Provider {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::AniList => "AniList",
|
||||
Self::Miruro => "Miruro legacy",
|
||||
}
|
||||
}
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::AniList => "anilist",
|
||||
Self::Miruro => "miruro",
|
||||
}
|
||||
}
|
||||
pub fn identity(self, media_id: String, episode: u32) -> io::Result<Identity> {
|
||||
let identity = Identity {
|
||||
provider: self.key().into(),
|
||||
media_id,
|
||||
episode,
|
||||
};
|
||||
identity.validate()?;
|
||||
Ok(identity)
|
||||
}
|
||||
pub fn includes(self, key: &str) -> bool {
|
||||
Identity::from_key(key).is_ok_and(|identity| identity.provider == self.key())
|
||||
}
|
||||
}
|
||||
|
||||
#[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 miruro(media_id: String, episode: u32) -> io::Result<Self> {
|
||||
Provider::Miruro.identity(media_id, episode)
|
||||
}
|
||||
pub fn anilist(media_id: String, episode: u32) -> io::Result<Self> {
|
||||
Provider::AniList.identity(media_id, episode)
|
||||
}
|
||||
pub fn validate(&self) -> io::Result<()> {
|
||||
if self.episode == 0 || self.episode > i32::MAX as u32 {
|
||||
return Err(invalid("Invalid anime episode number"));
|
||||
}
|
||||
match self.provider.as_str() {
|
||||
"anilist" => {
|
||||
positive_decimal(&self.media_id)?;
|
||||
}
|
||||
"miruro"
|
||||
if !self.media_id.is_empty()
|
||||
&& self.media_id.len() <= 512
|
||||
&& self
|
||||
.media_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) => {}
|
||||
_ => return Err(invalid("Invalid anime catalog identity")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn key(&self) -> String {
|
||||
format!("{}:{}:{}", self.provider, self.media_id, self.episode)
|
||||
}
|
||||
pub fn watch_url(&self) -> io::Result<String> {
|
||||
self.validate()?;
|
||||
Ok(match self.provider.as_str() {
|
||||
"anilist" => format!(
|
||||
"https://zokoanime.video/stream/ani/{}/{}/sub",
|
||||
self.media_id, self.episode
|
||||
),
|
||||
_ => format!(
|
||||
"https://barelystarted.miruro.tv/watch/{}/episode?ep={}",
|
||||
self.media_id, self.episode
|
||||
),
|
||||
})
|
||||
}
|
||||
pub fn from_key(key: &str) -> io::Result<Self> {
|
||||
let mut parts = key.split(':');
|
||||
let provider = parts.next().unwrap_or("");
|
||||
let media_id = parts.next().unwrap_or("");
|
||||
let episode = positive_decimal(parts.next().unwrap_or(""))?;
|
||||
if parts.next().is_some() {
|
||||
return Err(invalid("Invalid anime episode key"));
|
||||
}
|
||||
let identity = Self {
|
||||
provider: provider.into(),
|
||||
media_id: media_id.into(),
|
||||
episode,
|
||||
};
|
||||
identity.validate()?;
|
||||
Ok(identity)
|
||||
}
|
||||
}
|
||||
pub(crate) fn positive_decimal(text: &str) -> io::Result<u32> {
|
||||
if text.starts_with('0') || text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(invalid("Expected canonical positive decimal Int32"));
|
||||
}
|
||||
text.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|n| *n <= i32::MAX as u32)
|
||||
.ok_or_else(|| invalid("Expected canonical positive decimal Int32"))
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn canonical_provider_isolation() {
|
||||
let ani = Identity::anilist("1".into(), 1).unwrap();
|
||||
assert_eq!(ani.key(), "anilist:1:1");
|
||||
assert_eq!(
|
||||
ani.watch_url().unwrap(),
|
||||
"https://zokoanime.video/stream/ani/1/1/sub"
|
||||
);
|
||||
assert_ne!(ani.key(), Identity::miruro("1".into(), 1).unwrap().key());
|
||||
assert!(!Provider::Miruro.includes(&ani.key()));
|
||||
for key in [
|
||||
"anilist:01:1",
|
||||
"anilist:0:1",
|
||||
"anilist:+1:1",
|
||||
"anilist:1:01",
|
||||
"anilist:1:0",
|
||||
"anilist:2147483648:1",
|
||||
"anilist:1:2147483648",
|
||||
"anilist:1:1:2",
|
||||
"other:1:1",
|
||||
] {
|
||||
assert!(Identity::from_key(key).is_err(), "{key}");
|
||||
}
|
||||
assert_eq!(Identity::from_key(&ani.key()).unwrap(), ani);
|
||||
}
|
||||
}
|
||||
+16
-11
@@ -119,8 +119,8 @@ actions! {
|
||||
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
|
||||
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
|
||||
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
|
||||
VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"];
|
||||
VideoEnqueue, "video.enqueue", "Append checked videos in displayed order, or the selected video, to Terebi's queue", VIEW, ["ctrl+enter"];
|
||||
VideoPlay, "video.play", "Play checked videos in check order, or the selected video", VIEW, ["enter"];
|
||||
VideoEnqueue, "video.enqueue", "Append checked videos in check order, or the selected video, to Terebi's queue", VIEW, ["ctrl+enter"];
|
||||
VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"];
|
||||
VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"];
|
||||
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"];
|
||||
@@ -135,7 +135,8 @@ actions! {
|
||||
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
|
||||
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
|
||||
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
|
||||
ChannelTags, "channel.tags", "Edit the selected YouTube channel's or Miruro show's category tags", VIEW, ["s"];
|
||||
ChannelPlaylists, "channel.playlists", "Browse the current channel's playlists", &[Context::Normal], ["L"];
|
||||
ChannelTags, "channel.tags", "Edit the selected YouTube channel's or anime show's category tags", VIEW, ["s"];
|
||||
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
|
||||
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
|
||||
CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"];
|
||||
@@ -151,15 +152,15 @@ actions! {
|
||||
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
|
||||
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
|
||||
WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", VIEW, ["M"];
|
||||
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"];
|
||||
SelectorNext, "selector.select.next", "Select the next category, channel, or playlist", &[Context::SelectorNormal], ["j", "down"];
|
||||
SelectorPrevious, "selector.select.previous", "Select the previous category, channel, or playlist", &[Context::SelectorNormal], ["k", "up"];
|
||||
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
|
||||
SelectorInputPrevious, "selector.input.previous", "Select the previous match while editing the filter", &[Context::SelectorInput], ["up", "ctrl+k"];
|
||||
SelectorEdit, "selector.edit", "Edit the category, channel, or collection filter", &[Context::SelectorNormal], ["i"];
|
||||
SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"];
|
||||
SelectorEdit, "selector.edit", "Edit the category, channel, playlist, or collection filter", &[Context::SelectorNormal], ["i"];
|
||||
SelectorOpen, "selector.open", "Open the selected category, channel, playlist, or collection", &[Context::SelectorNormal], ["enter"];
|
||||
SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"];
|
||||
SelectorToggle, "selector.toggle", "Check a category for combined browsing (category picker only)", &[Context::SelectorNormal], ["space"];
|
||||
SelectorClose, "selector.close", "Close the category, channel, or collection picker", &[Context::SelectorNormal], ["escape"];
|
||||
SelectorClose, "selector.close", "Close the category, channel, playlist, or collection picker", &[Context::SelectorNormal], ["escape"];
|
||||
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
|
||||
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
|
||||
TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"];
|
||||
@@ -486,11 +487,11 @@ impl Bindings {
|
||||
(&[VideoNext, VideoPrevious], "Select next / previous video"),
|
||||
(
|
||||
&[VideoPlay],
|
||||
"Play checked videos in displayed order, or the selected video",
|
||||
"Play checked videos in check order, or the selected video",
|
||||
),
|
||||
(
|
||||
&[VideoEnqueue],
|
||||
"Append checked videos, or the selected video, to Terebi's queue",
|
||||
"Append checked videos in check order, or the selected video, to Terebi's queue",
|
||||
),
|
||||
(
|
||||
&[VideoToggle],
|
||||
@@ -519,6 +520,10 @@ impl Bindings {
|
||||
),
|
||||
(&[VideoChannel], "Browse the selected video's channel"),
|
||||
(&[ChannelBrowse], "Choose a saved channel"),
|
||||
(
|
||||
&[ChannelPlaylists],
|
||||
"Browse the current channel's playlists",
|
||||
),
|
||||
(&[CategoryBrowse], "Choose a saved category"),
|
||||
(&[ChannelTags], "Edit the selected channel's tags"),
|
||||
(&[CategoryTags], "Edit all channels in a category"),
|
||||
@@ -615,7 +620,7 @@ impl Bindings {
|
||||
],
|
||||
),
|
||||
(
|
||||
"CHANNEL / CATEGORY / COLLECTION SELECTOR",
|
||||
"CHANNEL / CATEGORY / PLAYLIST / COLLECTION SELECTOR",
|
||||
&[
|
||||
(&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"),
|
||||
(&[SelectorEdit], "Enter filter INSERT mode"),
|
||||
|
||||
+75
@@ -1 +1,76 @@
|
||||
pub mod anilist;
|
||||
pub mod anime;
|
||||
pub mod miruro;
|
||||
|
||||
/// Bound both wire bytes and decoded text: ureq's limit precedes decompression.
|
||||
fn read_catalog_body(body: &mut ureq::Body, limit: u64) -> std::io::Result<String> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut text = String::new();
|
||||
body.with_config()
|
||||
.limit(limit)
|
||||
.reader()
|
||||
.take(limit + 1)
|
||||
.read_to_string(&mut text)?;
|
||||
if text.len() as u64 > limit {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Catalog response exceeded decoded body limit",
|
||||
));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compressed_catalog_bodies_keep_the_decoded_size_limit() {
|
||||
use std::{io::Write, net::TcpListener, thread};
|
||||
|
||||
// Gzip of 64 spaces: fits the 32-byte wire limit, exceeds the decoded limit.
|
||||
const GZIP: &[u8] = &[
|
||||
31, 139, 8, 0, 0, 0, 0, 0, 2, 255, 83, 80, 160, 12, 0, 0, 95, 100, 117, 189, 64, 0, 0,
|
||||
0,
|
||||
];
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
use std::io::{BufRead, BufReader};
|
||||
for _ in 0..2 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut request = BufReader::new(&mut stream);
|
||||
let mut line = String::new();
|
||||
while request.read_line(&mut line).unwrap() > 0 {
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
write!(stream, "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", GZIP.len()).unwrap();
|
||||
stream.write_all(GZIP).unwrap();
|
||||
}
|
||||
});
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(5)))
|
||||
.build()
|
||||
.new_agent();
|
||||
let mut response = agent.get(&url).call().unwrap();
|
||||
assert_eq!(
|
||||
read_catalog_body(response.body_mut(), 64).unwrap(),
|
||||
" ".repeat(64)
|
||||
);
|
||||
let mut response = agent.get(&url).call().unwrap();
|
||||
assert_eq!(
|
||||
read_catalog_body(response.body_mut(), 32)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
std::io::ErrorKind::InvalidData
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+211
-87
File diff suppressed because one or more lines are too long
+82
-80
@@ -2,16 +2,32 @@
|
||||
//!
|
||||
//! See `docs/miruro-api.md`. Terebi owns playback source resolution, headers,
|
||||
//! subtitles and playback metadata.
|
||||
use std::{io, mem::size_of, time::Duration};
|
||||
use std::{
|
||||
io,
|
||||
mem::size_of,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(test)]
|
||||
use crate::anime::Identity;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const BASE: &str = "https://barelystarted.miruro.tv";
|
||||
// Miruro 2.0 links from https://www.miruro.com/#domains; www hosts are legacy.
|
||||
const MIRRORS: &[&str] = &[
|
||||
"https://miruro.cx",
|
||||
"https://miruro.pw",
|
||||
"https://miruro.ws",
|
||||
"https://miruro.to",
|
||||
"https://miruro.tv",
|
||||
"https://miruro.bz",
|
||||
];
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(25);
|
||||
const BODY_LIMIT: u64 = 2 * 1024 * 1024;
|
||||
|
||||
pub struct Client {
|
||||
agent: ureq::Agent,
|
||||
preferred: AtomicUsize,
|
||||
}
|
||||
|
||||
/// Catalog metadata and available episode metadata, not a playback guarantee.
|
||||
@@ -20,76 +36,16 @@ pub struct Detail {
|
||||
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)
|
||||
}
|
||||
|
||||
/// The slug is non-identifying; metadata readers must resolve the canonical route.
|
||||
pub fn watch_url(&self) -> io::Result<String> {
|
||||
self.validate()?;
|
||||
Ok(format!(
|
||||
"{BASE}/watch/{}/episode?ep={}",
|
||||
self.media_id, self.episode
|
||||
))
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> io::Result<Self> {
|
||||
let (media_id, episode) = key
|
||||
.strip_prefix("miruro:")
|
||||
.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"))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Client {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent: ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(25)))
|
||||
.timeout_global(Some(REQUEST_TIMEOUT))
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.new_agent(),
|
||||
preferred: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,12 +90,42 @@ impl Client {
|
||||
}
|
||||
|
||||
fn get(&self, path: &str, query: &[(&str, &str)], types: &[&str]) -> io::Result<String> {
|
||||
let mut response = self
|
||||
self.get_from(MIRRORS, path, query, types)
|
||||
}
|
||||
|
||||
fn get_from(
|
||||
&self,
|
||||
mirrors: &[&str],
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
types: &[&str],
|
||||
) -> io::Result<String> {
|
||||
let started = Instant::now();
|
||||
let first = self.preferred.load(Ordering::Relaxed) % mirrors.len();
|
||||
let mut last_error = io::Error::new(io::ErrorKind::TimedOut, "Miruro request timed out");
|
||||
let mut attempts = 0;
|
||||
for offset in 0..mirrors.len() {
|
||||
let Some(remaining) = REQUEST_TIMEOUT.checked_sub(started.elapsed()) else {
|
||||
break;
|
||||
};
|
||||
let index = (first + offset) % mirrors.len();
|
||||
let base = mirrors[index];
|
||||
attempts += 1;
|
||||
let response = self
|
||||
.agent
|
||||
.get(format!("{BASE}{path}"))
|
||||
.get(format!("{base}{path}"))
|
||||
.query_pairs(query.iter().copied())
|
||||
.call()
|
||||
.map_err(|_| io::Error::other("Miruro HTTP transport failed"))?;
|
||||
.config()
|
||||
.timeout_global(Some(remaining.min(Duration::from_secs(5))))
|
||||
.build()
|
||||
.call();
|
||||
let mut response = match response {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
last_error = io::Error::other(format!("{base}: HTTP transport failed"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
let mime = response.body().mime_type().unwrap_or("missing");
|
||||
if status != 200 {
|
||||
@@ -148,20 +134,36 @@ impl Client {
|
||||
404 => io::ErrorKind::NotFound,
|
||||
_ => io::ErrorKind::Other,
|
||||
};
|
||||
return Err(io::Error::new(
|
||||
kind,
|
||||
format!("Miruro HTTP {status} ({mime}); no retry attempted"),
|
||||
));
|
||||
last_error = io::Error::new(kind, format!("{base}: HTTP {status} ({mime})"));
|
||||
// Do not rotate hosts to work around rate limits or invalid requests.
|
||||
if !matches!(status, 301..=308 | 403 | 404 | 408 | 500..=599) {
|
||||
return Err(last_error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !types.contains(&mime) {
|
||||
return Err(invalid(format!("Unexpected Miruro Content-Type: {mime}")));
|
||||
last_error = invalid(format!("{base}: unexpected Content-Type {mime}"));
|
||||
continue;
|
||||
}
|
||||
response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(BODY_LIMIT)
|
||||
.read_to_string()
|
||||
.map_err(|_| invalid("Miruro response was unreadable, timed out, or exceeded 2 MiB"))
|
||||
match crate::read_catalog_body(response.body_mut(), BODY_LIMIT) {
|
||||
Ok(body) => {
|
||||
self.preferred.store(index, Ordering::Relaxed);
|
||||
return Ok(body);
|
||||
}
|
||||
Err(_) => {
|
||||
last_error = invalid(format!(
|
||||
"{base}: response unreadable, timed out, or exceeded 2 MiB"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
last_error.kind(),
|
||||
format!(
|
||||
"Miruro request failed on {attempts}/{} mirrors; last error: {last_error}",
|
||||
mirrors.len()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-12
@@ -5,7 +5,10 @@ use std::{
|
||||
sync::mpsc::{self, Receiver, SyncSender},
|
||||
thread,
|
||||
};
|
||||
use tubular::miruro::{Client, Detail, Identity};
|
||||
use tubular::{
|
||||
anime::{Identity, Provider},
|
||||
miruro::{Client, Detail},
|
||||
};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -28,7 +31,7 @@ pub struct Episode {
|
||||
|
||||
impl Episode {
|
||||
pub fn snapshot(&self, media: &Media) -> Video {
|
||||
video(
|
||||
let mut snapshot = video(
|
||||
self.identity.key(),
|
||||
format!(
|
||||
"{} · Episode {}{}",
|
||||
@@ -40,7 +43,11 @@ impl Episode {
|
||||
.unwrap_or_default()
|
||||
),
|
||||
self.aired_on.clone(),
|
||||
)
|
||||
);
|
||||
if self.identity.provider == "anilist" {
|
||||
snapshot.channel = Some("AniList".into());
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,21 +74,30 @@ pub struct Worker {
|
||||
generation: u64,
|
||||
}
|
||||
impl Worker {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(provider: Provider) -> Self {
|
||||
let (sender, requests) = mpsc::sync_channel::<(u64, Request)>(1);
|
||||
let (results, receiver) = mpsc::sync_channel(1);
|
||||
thread::spawn(move || {
|
||||
let client = Client::default();
|
||||
let anilist = tubular::anilist::Client::default();
|
||||
while let Ok((generation, request)) = requests.recv() {
|
||||
let result = match request {
|
||||
Request::Search(query) => client
|
||||
let result = match (provider, request) {
|
||||
(Provider::Miruro, Request::Search(query)) => client
|
||||
.search(&query)
|
||||
.and_then(catalog)
|
||||
.map(Response::Catalog),
|
||||
Request::Episodes { media_id, slug } => client
|
||||
(Provider::Miruro, Request::Episodes { media_id, slug }) => client
|
||||
.detail(&media_id, &slug)
|
||||
.and_then(|detail| series(&media_id, detail))
|
||||
.map(Response::Series),
|
||||
(Provider::AniList, Request::Search(query)) => anilist
|
||||
.search(&query)
|
||||
.and_then(catalog)
|
||||
.map(Response::Catalog),
|
||||
(Provider::AniList, Request::Episodes { media_id, .. }) => anilist
|
||||
.detail(&media_id)
|
||||
.and_then(anilist_series)
|
||||
.map(Response::Series),
|
||||
};
|
||||
if results.send((generation, result)).is_err() {
|
||||
break;
|
||||
@@ -97,11 +113,11 @@ impl Worker {
|
||||
}
|
||||
pub fn submit(&mut self, generation: u64, request: Request) -> io::Result<()> {
|
||||
if self.busy {
|
||||
return Err(io::Error::other("Miruro worker is busy"));
|
||||
return Err(io::Error::other("Anime catalog worker is busy"));
|
||||
}
|
||||
self.sender
|
||||
.try_send((generation, request))
|
||||
.map_err(|_| io::Error::other("Miruro worker unavailable"))?;
|
||||
.map_err(|_| io::Error::other("Anime catalog worker unavailable"))?;
|
||||
self.busy = true;
|
||||
self.generation = generation;
|
||||
Ok(())
|
||||
@@ -117,7 +133,7 @@ impl Worker {
|
||||
self.busy = false;
|
||||
Some((
|
||||
self.generation,
|
||||
Err(io::Error::other("Miruro worker stopped")),
|
||||
Err(io::Error::other("Anime catalog worker stopped")),
|
||||
))
|
||||
}
|
||||
Err(_) => None,
|
||||
@@ -203,7 +219,7 @@ fn media(metadata: Value) -> io::Result<Media> {
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| invalid("Miruro catalog ID is missing"))?
|
||||
.to_owned();
|
||||
Identity::new(id.clone(), 1)?;
|
||||
Identity::miruro(id.clone(), 1)?;
|
||||
Ok(Media {
|
||||
id,
|
||||
title: title(&metadata)?,
|
||||
@@ -243,7 +259,7 @@ pub fn series(media_id: &str, detail: Detail) -> io::Result<Series> {
|
||||
.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)?;
|
||||
let identity = Identity::miruro(media_id.to_owned(), number)?;
|
||||
if !seen.insert(number) {
|
||||
return Err(invalid("Miruro returned duplicate episode numbers"));
|
||||
}
|
||||
@@ -266,6 +282,46 @@ pub fn series(media_id: &str, detail: Detail) -> io::Result<Series> {
|
||||
Ok(Series { media, episodes })
|
||||
}
|
||||
|
||||
pub fn media_provider(media: &Media) -> Provider {
|
||||
if media
|
||||
.metadata
|
||||
.get("catalog_provider")
|
||||
.and_then(Value::as_str)
|
||||
== Some("anilist")
|
||||
{
|
||||
Provider::AniList
|
||||
} else {
|
||||
Provider::Miruro
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anilist_episode(media: &Media, number: u32) -> io::Result<Episode> {
|
||||
let identity = Identity::anilist(media.id.clone(), number)?;
|
||||
Ok(Episode {
|
||||
identity,
|
||||
title: None,
|
||||
synopsis: Some("Catalog numbering does not guarantee a stream. Visible episodes are checked separately for ZokoAnime sub sources; playback always resolves fresh.".into()),
|
||||
aired_on: None,
|
||||
duration_seconds: None,
|
||||
metadata: Value::Null,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn anilist_series(metadata: Value) -> io::Result<Series> {
|
||||
let media = media(metadata)?;
|
||||
Identity::anilist(media.id.clone(), 1)?;
|
||||
let count = tubular::anilist::catalog_count(&media.metadata)?;
|
||||
if count.is_some_and(|count| count > 10000) {
|
||||
return Err(invalid(
|
||||
"AniList catalog exceeds the 10000 episode display limit",
|
||||
));
|
||||
}
|
||||
let episodes = (1..=count.unwrap_or(0))
|
||||
.map(|number| anilist_episode(&media, number))
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
Ok(Series { media, episodes })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/unit/miruro_browser.rs"]
|
||||
mod tests;
|
||||
|
||||
+335
-49
@@ -21,7 +21,7 @@ use ratatui::{
|
||||
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tubular::miruro::Identity;
|
||||
use tubular::anime::{Identity, Provider};
|
||||
|
||||
use crate::{
|
||||
Options, PreviewMode, config,
|
||||
@@ -33,6 +33,9 @@ use crate::{
|
||||
youtube::{PlayerLaunch, Video},
|
||||
};
|
||||
|
||||
#[path = "source_check.rs"]
|
||||
mod source_check;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum WatchFilter {
|
||||
#[default]
|
||||
@@ -92,8 +95,9 @@ struct DisplayRow {
|
||||
}
|
||||
struct Screen {
|
||||
content: Content,
|
||||
provider: Provider,
|
||||
selection: ListState,
|
||||
checked: BTreeSet<String>,
|
||||
checked: Vec<String>,
|
||||
description_scroll: u16,
|
||||
error: Option<String>,
|
||||
title: String,
|
||||
@@ -105,8 +109,9 @@ impl Screen {
|
||||
fn new(content: Content) -> Self {
|
||||
let mut screen = Self {
|
||||
content,
|
||||
provider: Provider::Miruro,
|
||||
selection: ListState::default(),
|
||||
checked: BTreeSet::new(),
|
||||
checked: Vec::new(),
|
||||
description_scroll: 0,
|
||||
error: None,
|
||||
title: String::new(),
|
||||
@@ -163,10 +168,13 @@ impl Screen {
|
||||
}
|
||||
fn toggle_checked(&mut self) {
|
||||
if self.playable()
|
||||
&& let Some(id) = self.selected_key().map(str::to_owned)
|
||||
&& let Some(id) = self.selected_key()
|
||||
{
|
||||
if !self.checked.remove(&id) {
|
||||
self.checked.insert(id);
|
||||
if let Some(index) = self.checked.iter().position(|checked| checked == id) {
|
||||
self.checked.remove(index);
|
||||
} else {
|
||||
let id = id.to_owned();
|
||||
self.checked.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,22 +182,24 @@ impl Screen {
|
||||
if self.checked.is_empty() {
|
||||
return self.selected_video().into_iter().collect();
|
||||
}
|
||||
// ponytail: scan rows per check; index IDs if very large lists need it.
|
||||
self.checked
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let index = self
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.key.as_ref() == Some(id))?;
|
||||
match &self.content {
|
||||
Content::Series {
|
||||
media,
|
||||
data: Some(data),
|
||||
} => data
|
||||
.iter()
|
||||
.filter(|episode| self.checked.contains(&episode.identity.key()))
|
||||
.map(|episode| episode.snapshot(media))
|
||||
.collect(),
|
||||
Content::Collection { videos, .. } => videos
|
||||
.iter()
|
||||
.filter(|video| self.checked.contains(&video.id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
} => data.get(index).map(|episode| episode.snapshot(media)),
|
||||
Content::Collection { videos, .. } => videos.get(index).cloned(),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn selected_key(&self) -> Option<&str> {
|
||||
self.selection
|
||||
@@ -218,7 +228,7 @@ impl Screen {
|
||||
self.overview.clear();
|
||||
match &self.content {
|
||||
Content::Catalog { media, .. } => {
|
||||
self.title = "Miruro · Catalog (autocomplete, up to 8 titles)".into();
|
||||
self.title = format!("{} · Catalog (up to 8 titles)", self.provider.name());
|
||||
self.rows = media
|
||||
.iter()
|
||||
.map(|media| DisplayRow {
|
||||
@@ -230,13 +240,30 @@ impl Screen {
|
||||
.collect();
|
||||
}
|
||||
Content::Series { media, data } => {
|
||||
self.title = format!("Miruro · {} · Episodes", safe(&media.title));
|
||||
self.title = format!(
|
||||
"{} · {} · Episodes",
|
||||
self.provider.name(),
|
||||
safe(&media.title)
|
||||
);
|
||||
self.overview = media_details(media);
|
||||
if let Some(data) = data {
|
||||
if self.provider == Provider::AniList {
|
||||
self.overview.push_str("\n\nCatalog numbers are not a stream inventory. Visible episodes are checked for a fresh ZokoAnime sub source; playback resolves again.");
|
||||
if media
|
||||
.metadata
|
||||
.get("catalog_count")
|
||||
.is_none_or(Value::is_null)
|
||||
{
|
||||
self.overview.push_str("\nCatalog/airing count unknown. Use search/edit (i by default) here to select an explicit positive episode number.");
|
||||
} else {
|
||||
self.overview.push_str(&format!("\n{} catalog/airing episode numbers (planned totals are not availability).", data.len()));
|
||||
}
|
||||
} else {
|
||||
self.overview.push_str(&format!(
|
||||
"\n\n{} returned episodes (source availability is checked on play).",
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
self.rows = data
|
||||
.iter()
|
||||
.map(|episode| {
|
||||
@@ -256,6 +283,26 @@ impl Screen {
|
||||
if let Some(seconds) = episode.duration_seconds {
|
||||
details.push(format!("{}:{:02}", seconds / 60, seconds % 60));
|
||||
}
|
||||
if self.provider == Provider::AniList {
|
||||
if let Some(minutes) = media
|
||||
.metadata
|
||||
.get("episode_duration_minutes")
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|minutes| *minutes > 0)
|
||||
{
|
||||
details.push(format!("{minutes} min avg"));
|
||||
}
|
||||
if let Some(format) =
|
||||
media.metadata.get("format").and_then(Value::as_str)
|
||||
{
|
||||
details.push(safe(format));
|
||||
}
|
||||
if let Some(year) =
|
||||
media.metadata.get("season_year").and_then(Value::as_u64)
|
||||
{
|
||||
details.push(format!("{year} series"));
|
||||
}
|
||||
}
|
||||
let details = details.join(" · ");
|
||||
let mut metadata = title.clone();
|
||||
if !details.is_empty() {
|
||||
@@ -292,7 +339,8 @@ impl Screen {
|
||||
videos,
|
||||
} => {
|
||||
self.title = format!(
|
||||
"Miruro · {} episodes{}",
|
||||
"{} · {} episodes{}",
|
||||
self.provider.name(),
|
||||
collection_name(*kind),
|
||||
if matches!(kind, CollectionKind::WatchLater) {
|
||||
format!(" · {}", filter.label())
|
||||
@@ -303,13 +351,14 @@ impl Screen {
|
||||
self.rows = videos.iter().map(|video| {
|
||||
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
|
||||
let title = safe(&video.title);
|
||||
let metadata = format!("{title}\n\nEpisode {} · Miruro ID {}\n\nStored offline episode metadata. Enter sends the episode URL to Terebi, which resolves sources and playback metadata.", identity.episode, identity.media_id);
|
||||
let metadata = format!("{title}\n\nEpisode {} · {} ID {}\n\nStored offline episode metadata. Catalog numbers do not establish source availability. Enter sends the stable episode URL to Terebi for fresh resolution.", identity.episode, self.provider.name(), identity.media_id);
|
||||
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
|
||||
}).collect();
|
||||
}
|
||||
Content::Library { categories, shows } => {
|
||||
self.title = format!(
|
||||
"Miruro · Saved shows · {}",
|
||||
"{} · Saved shows · {}",
|
||||
self.provider.name(),
|
||||
if categories.is_empty() {
|
||||
"All saved shows".into()
|
||||
} else {
|
||||
@@ -342,6 +391,8 @@ impl Screen {
|
||||
// History owns the actual screens, so back/forward never has to refetch a catalog.
|
||||
pub(crate) struct Browser {
|
||||
options: Options,
|
||||
provider: Provider,
|
||||
episode_input: bool,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
pub(crate) state: State,
|
||||
@@ -359,6 +410,8 @@ pub(crate) struct Browser {
|
||||
pending: Option<Request>,
|
||||
intent: Option<Intent>,
|
||||
player: Option<PlayerLaunch>,
|
||||
source_checks: source_check::Checks,
|
||||
source_visible: Vec<String>,
|
||||
}
|
||||
enum Intent {
|
||||
Catalog,
|
||||
@@ -430,11 +483,19 @@ impl Browser {
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
state: State,
|
||||
mut state: State,
|
||||
) -> Self {
|
||||
let provider = if options.anilist {
|
||||
Provider::AniList
|
||||
} else {
|
||||
Provider::Miruro
|
||||
};
|
||||
state.select_catalog(provider);
|
||||
let mut query = String::new();
|
||||
crate::paste_input(&mut query, options.query.as_deref().unwrap_or(""));
|
||||
let mut browser = Self {
|
||||
provider,
|
||||
episode_input: false,
|
||||
options,
|
||||
config_path,
|
||||
config,
|
||||
@@ -457,7 +518,11 @@ impl Browser {
|
||||
pending: None,
|
||||
intent: None,
|
||||
player: None,
|
||||
source_checks: source_check::Checks::default(),
|
||||
source_visible: Vec::new(),
|
||||
};
|
||||
browser.screens[0].provider = provider;
|
||||
browser.screens[0].rebuild_display();
|
||||
if matches!(&browser.screen().content, Content::Catalog { query, .. } if !query.trim().is_empty())
|
||||
{
|
||||
browser.search();
|
||||
@@ -470,7 +535,53 @@ impl Browser {
|
||||
fn screen_mut(&mut self) -> &mut Screen {
|
||||
&mut self.screens[self.index]
|
||||
}
|
||||
fn sources_exposed(&self) -> bool {
|
||||
self.provider == Provider::AniList
|
||||
&& self.screen().playable()
|
||||
&& self.intent.is_none()
|
||||
&& self.pending.is_none()
|
||||
&& self.input.is_none()
|
||||
&& self.preview.is_none()
|
||||
&& self.help.is_none()
|
||||
&& self.removal.is_none()
|
||||
&& self.modal.is_none()
|
||||
}
|
||||
fn update_visible_sources(&mut self, height: u16) {
|
||||
if !self.sources_exposed() {
|
||||
self.source_visible.clear();
|
||||
return;
|
||||
}
|
||||
// ListState's offset is updated by rendering, not by cursor movement.
|
||||
// Only complete three-line rows fit; a spare terminal line is not another episode.
|
||||
let screen = &self.screens[self.index];
|
||||
let keys = screen
|
||||
.rows
|
||||
.iter()
|
||||
.skip(screen.selection.offset())
|
||||
.take(usize::from(height) / 3)
|
||||
.filter_map(|row| row.key.as_ref())
|
||||
.filter(|key| key.starts_with("anilist:"));
|
||||
if self.source_visible.iter().eq(keys.clone()) {
|
||||
return;
|
||||
}
|
||||
self.source_visible.clear();
|
||||
self.source_visible.extend(keys.cloned());
|
||||
}
|
||||
fn update_source_checks(&mut self, now: Instant) {
|
||||
if !self.sources_exposed() {
|
||||
self.source_visible.clear();
|
||||
}
|
||||
self.source_checks.update(
|
||||
self.generation,
|
||||
&self.source_visible,
|
||||
self.screens[self.index].selected_key(),
|
||||
&self.options.player,
|
||||
now,
|
||||
);
|
||||
}
|
||||
fn invalidate(&mut self) {
|
||||
self.source_checks.cancel();
|
||||
self.source_visible.clear();
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
self.pending = None;
|
||||
self.intent = None;
|
||||
@@ -481,7 +592,9 @@ impl Browser {
|
||||
self.intent = Some(intent);
|
||||
self.screen_mut().error = None;
|
||||
}
|
||||
fn push(&mut self, screen: Screen) {
|
||||
fn push(&mut self, mut screen: Screen) {
|
||||
screen.provider = self.provider;
|
||||
screen.rebuild_display();
|
||||
self.invalidate();
|
||||
self.screens.truncate(self.index + 1);
|
||||
self.screens.push(screen);
|
||||
@@ -511,6 +624,18 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn edit_search(&mut self) {
|
||||
self.episode_input = false;
|
||||
if self.provider == Provider::AniList
|
||||
&& matches!(&self.screen().content, Content::Series { media, data: Some(_) } if media.metadata.get("catalog_count").is_none_or(Value::is_null))
|
||||
{
|
||||
self.invalidate();
|
||||
self.preview = None;
|
||||
self.input = Some(String::new());
|
||||
self.episode_input = true;
|
||||
self.status =
|
||||
"Enter an explicit episode number; source checked separately (sub only).".into();
|
||||
return;
|
||||
}
|
||||
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
||||
self.push(Screen::new(Content::Catalog {
|
||||
query: String::new(),
|
||||
@@ -524,6 +649,36 @@ impl Browser {
|
||||
}
|
||||
self.preview = None;
|
||||
}
|
||||
fn select_episode(&mut self, input: &str) {
|
||||
let result = (|| -> io::Result<()> {
|
||||
let Content::Series {
|
||||
media,
|
||||
data: Some(data),
|
||||
} = &mut self.screen_mut().content
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let identity = Identity::from_key(&format!("anilist:{}:{}", media.id, input.trim()))?;
|
||||
let episode = miruro_browser::anilist_episode(media, identity.episode)?;
|
||||
let index = data
|
||||
.iter()
|
||||
.position(|item| item.identity == identity)
|
||||
.unwrap_or(data.len());
|
||||
if index == data.len() {
|
||||
data.push(episode);
|
||||
}
|
||||
self.screen_mut().selection.select(Some(index));
|
||||
self.screen_mut().rebuild_display();
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.screen_mut().error = None;
|
||||
self.status = "Explicit episode selected; source checked separately. Enter resolves sub fresh.".into();
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
}
|
||||
fn search(&mut self) {
|
||||
let Content::Catalog { query, .. } = &self.screen().content else {
|
||||
return;
|
||||
@@ -545,14 +700,18 @@ impl Browser {
|
||||
return;
|
||||
}
|
||||
self.request(Request::Search(query), Intent::Catalog);
|
||||
self.status = "Searching Miruro catalog…".into();
|
||||
self.status = format!("Searching {} catalog…", self.provider.name());
|
||||
}
|
||||
fn load_series(&mut self) {
|
||||
let Content::Series { media, .. } = &self.screen().content else {
|
||||
return;
|
||||
};
|
||||
let id = media.id.clone();
|
||||
let slug = miruro_browser::slug(&media.title);
|
||||
let slug = if self.provider == Provider::AniList {
|
||||
Ok(String::new())
|
||||
} else {
|
||||
miruro_browser::slug(&media.title)
|
||||
};
|
||||
self.screen_mut().checked.clear();
|
||||
match slug {
|
||||
Ok(slug) => {
|
||||
@@ -563,7 +722,7 @@ impl Browser {
|
||||
},
|
||||
Intent::Series(id),
|
||||
);
|
||||
self.status = "Loading returned episodes…".into();
|
||||
self.status = "Loading catalog episode metadata…".into();
|
||||
}
|
||||
Err(error) => {
|
||||
self.invalidate();
|
||||
@@ -575,8 +734,21 @@ impl Browser {
|
||||
if let Some(media) = self.screen().selected_media().cloned()
|
||||
&& !matches!(self.screen().content, Content::Series { .. })
|
||||
{
|
||||
if self.provider == Provider::AniList
|
||||
&& matches!(self.screen().content, Content::Catalog { .. })
|
||||
{
|
||||
// AniList search already returns the same metadata as detail.
|
||||
match miruro_browser::anilist_series(media.metadata) {
|
||||
Ok(series) => self.push(Screen::new(Content::Series {
|
||||
media: series.media,
|
||||
data: Some(series.episodes),
|
||||
})),
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
} else {
|
||||
self.push(Screen::new(Content::Series { media, data: None }));
|
||||
self.load_series();
|
||||
}
|
||||
} else {
|
||||
self.play(false);
|
||||
}
|
||||
@@ -671,7 +843,7 @@ impl Browser {
|
||||
}
|
||||
(_, Err(error)) => self.fail(error),
|
||||
_ => self.fail(io::Error::other(
|
||||
"Miruro response did not match the current request",
|
||||
"Anime catalog response did not match the current request",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -681,7 +853,8 @@ impl Browser {
|
||||
}
|
||||
if self.pending.is_some() && !self.worker.as_ref().is_some_and(|worker| worker.busy) {
|
||||
let request = self.pending.take().expect("pending request");
|
||||
let worker = self.worker.get_or_insert_with(Worker::new);
|
||||
let provider = self.provider;
|
||||
let worker = self.worker.get_or_insert_with(|| Worker::new(provider));
|
||||
if let Err(error) = worker.submit(self.generation, request) {
|
||||
self.intent = None;
|
||||
self.fail(error);
|
||||
@@ -704,7 +877,7 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn collection(&mut self, kind: CollectionKind) {
|
||||
let videos = collection_videos(&self.state, kind, WatchFilter::All);
|
||||
let videos = collection_videos(&self.state, kind, WatchFilter::All, self.provider);
|
||||
let screen = Screen::new(Content::Collection {
|
||||
kind,
|
||||
filter: WatchFilter::All,
|
||||
@@ -947,7 +1120,7 @@ impl Browser {
|
||||
.selected()
|
||||
.and_then(|index| videos.get(index))
|
||||
.map(|video| video.id.clone());
|
||||
let replacement = collection_videos(&self.state, *kind, *filter);
|
||||
let replacement = collection_videos(&self.state, *kind, *filter, self.provider);
|
||||
let next = selected
|
||||
.and_then(|id| replacement.iter().position(|video| video.id == id))
|
||||
.or(screen.selection.selected());
|
||||
@@ -1027,7 +1200,7 @@ impl Browser {
|
||||
// Even the confirmation snapshot is provider-scoped at the mutation seam.
|
||||
let ids: Vec<_> = ids
|
||||
.into_iter()
|
||||
.filter(|id| Identity::from_key(id).is_ok())
|
||||
.filter(|id| self.provider.includes(id))
|
||||
.collect();
|
||||
match self.state.remove(kind, &ids) {
|
||||
Ok(()) => {
|
||||
@@ -1043,6 +1216,7 @@ impl Browser {
|
||||
}
|
||||
fn refresh(&mut self) {
|
||||
self.invalidate();
|
||||
self.source_checks.clear();
|
||||
self.screen_mut().checked.clear();
|
||||
match config::load(&self.config_path) {
|
||||
Ok(config) => {
|
||||
@@ -1192,12 +1366,20 @@ impl Browser {
|
||||
match action {
|
||||
Some(Action::InputSubmit) => {
|
||||
let input = self.input.take().expect("input active");
|
||||
if self.episode_input {
|
||||
self.episode_input = false;
|
||||
self.select_episode(&input);
|
||||
} else {
|
||||
if let Content::Catalog { query, .. } = &mut self.screen_mut().content {
|
||||
*query = input;
|
||||
}
|
||||
self.search();
|
||||
}
|
||||
Some(Action::InputCancel) => self.input = None,
|
||||
}
|
||||
Some(Action::InputCancel) => {
|
||||
self.input = None;
|
||||
self.episode_input = false;
|
||||
}
|
||||
_ => {
|
||||
// Typed controls are rejected too, not merely bracketed paste.
|
||||
if !matches!(key.code, KeyCode::Char(ch) if ch.is_control()) {
|
||||
@@ -1211,7 +1393,17 @@ impl Browser {
|
||||
Some(Action::ChannelTags) => self.tags(),
|
||||
Some(Action::CategoryBrowse) => self.category_picker(),
|
||||
Some(Action::Help) => self.help = Some(0),
|
||||
Some(Action::SearchEdit | Action::ViewSearch) => self.edit_search(),
|
||||
Some(Action::SearchEdit) => self.edit_search(),
|
||||
Some(Action::ViewSearch) => {
|
||||
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
||||
self.push(Screen::new(Content::Catalog {
|
||||
query: String::new(),
|
||||
media: Vec::new(),
|
||||
searched: false,
|
||||
}));
|
||||
}
|
||||
self.edit_search();
|
||||
}
|
||||
Some(Action::ViewPrevious) => self.history(false),
|
||||
Some(Action::ViewNext) => self.history(true),
|
||||
Some(Action::ViewRefresh) => self.refresh(),
|
||||
@@ -1318,7 +1510,14 @@ impl Browser {
|
||||
}
|
||||
if self.input.is_some() {
|
||||
return vec![
|
||||
(Action::InputSubmit, "Search catalog"),
|
||||
(
|
||||
Action::InputSubmit,
|
||||
if self.episode_input {
|
||||
"Select explicit episode (sub)"
|
||||
} else {
|
||||
"Search catalog"
|
||||
},
|
||||
),
|
||||
(Action::InputCancel, "Cancel input"),
|
||||
(Action::DeleteCharacter, "Delete character"),
|
||||
(Action::DeleteWord, "Delete word"),
|
||||
@@ -1437,7 +1636,16 @@ impl Browser {
|
||||
{
|
||||
let mut actions = vec![
|
||||
(Action::Help, "help"),
|
||||
(Action::SearchEdit, "search"),
|
||||
(
|
||||
Action::SearchEdit,
|
||||
if self.provider == Provider::AniList
|
||||
&& matches!(&self.screen().content, Content::Series { media, .. } if media.metadata.get("catalog_count").is_none_or(Value::is_null))
|
||||
{
|
||||
"select episode"
|
||||
} else {
|
||||
"search"
|
||||
},
|
||||
),
|
||||
(Action::InfoOverview, "info"),
|
||||
(
|
||||
Action::VideoPlay,
|
||||
@@ -1673,7 +1881,7 @@ impl Browser {
|
||||
} else {
|
||||
" · "
|
||||
};
|
||||
ListItem::new(vec![
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
ratatui::text::Span::raw(if self.screen().playable() {
|
||||
if row
|
||||
@@ -1698,21 +1906,39 @@ impl Browser {
|
||||
])
|
||||
.style(style),
|
||||
Line::from(vec![
|
||||
ratatui::text::Span::raw(if self.screen().playable() { " " } else { "" }),
|
||||
ratatui::text::Span::raw(row.details.as_str()),
|
||||
ratatui::text::Span::raw(separator),
|
||||
ratatui::text::Span::raw(markers),
|
||||
])
|
||||
.dim(),
|
||||
];
|
||||
if self.provider == Provider::AniList && self.screen().playable() {
|
||||
let label = row
|
||||
.key
|
||||
.as_deref()
|
||||
.map(|key| self.source_checks.label(key))
|
||||
.unwrap_or("Source check pending");
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
ratatui::text::Span::raw(" "),
|
||||
ratatui::text::Span::raw(label),
|
||||
])
|
||||
.dim(),
|
||||
);
|
||||
}
|
||||
ListItem::new(lines)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
|
||||
let area = frame.area();
|
||||
if area.width < 24 || area.height < 10 {
|
||||
self.source_visible.clear();
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"Miruro\nTerminal too small; enlarge to browse.\n{} exits",
|
||||
"{}\nTerminal too small; enlarge to browse.\n{} exits",
|
||||
self.provider.name(),
|
||||
self.config.keys.label(Action::Quit)
|
||||
))
|
||||
.wrap(Wrap { trim: true }),
|
||||
@@ -1744,7 +1970,13 @@ impl Browser {
|
||||
ui::search(
|
||||
frame,
|
||||
query_area,
|
||||
" tubular / Miruro search ",
|
||||
if self.episode_input {
|
||||
" Explicit episode · sub "
|
||||
} else if self.provider == Provider::AniList {
|
||||
" tubular / AniList search "
|
||||
} else {
|
||||
" tubular / Miruro legacy search "
|
||||
},
|
||||
query,
|
||||
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
|
||||
);
|
||||
@@ -1758,7 +1990,9 @@ impl Browser {
|
||||
"",
|
||||
self.intent.is_some().then(|| ui::spinner(elapsed)),
|
||||
));
|
||||
let viewport_height = block.inner(content).height;
|
||||
if self.screen().rows.is_empty() {
|
||||
self.source_visible.clear();
|
||||
let empty = if let Some(error) = &self.screen().error {
|
||||
format!(
|
||||
"{error}\n\n{} refreshes; {} searches the catalog.",
|
||||
@@ -1766,13 +2000,15 @@ impl Browser {
|
||||
self.config.keys.label(Action::SearchEdit)
|
||||
)
|
||||
} else if self.intent.is_some() {
|
||||
"Loading Miruro metadata…".into()
|
||||
format!("Loading {} metadata…", self.provider.name())
|
||||
} else {
|
||||
match &self.screen().content {
|
||||
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result to browse its actual returned episodes.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
|
||||
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result for catalog episodes; visible AniList episodes are checked separately for sources.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
|
||||
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
|
||||
Content::Series { media, .. } if self.provider == Provider::AniList && media.metadata.get("catalog_count").is_none_or(Value::is_null) => format!("Catalog/airing episode count unknown, not zero. {} selects an explicit positive episode number. Source availability is checked separately; sub resolves fresh on play.", self.config.keys.label(Action::SearchEdit)),
|
||||
Content::Series { .. } if self.provider == Provider::AniList => "No catalog/airing episode numbers known yet. Planned totals do not establish aired episodes or streams.".into(),
|
||||
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
|
||||
Content::Collection { .. } => "No Miruro episodes in this collection/filter. Save episodes from a series; YouTube entries are not shown here.".into(),
|
||||
Content::Collection { .. } => format!("No {} episodes in this collection/filter. Other catalogs and YouTube are separate.", self.provider.name()),
|
||||
Content::Library { .. } => "No saved shows in these categories. Save a catalog title with the show tags action, then confirm.".into(),
|
||||
}
|
||||
};
|
||||
@@ -1791,6 +2027,7 @@ impl Browser {
|
||||
&mut selection,
|
||||
);
|
||||
self.screen_mut().selection = selection;
|
||||
self.update_visible_sources(viewport_height);
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(self.status.as_str())
|
||||
@@ -1890,7 +2127,7 @@ impl Browser {
|
||||
if let Some((kind, ids)) = &self.removal {
|
||||
frame.render_widget(Clear, popup);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("Remove {} Miruro episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other collections, YouTube entries and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), collection_name(*kind)))
|
||||
Paragraph::new(format!("Remove {} {} episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other providers, collections and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), self.provider.name(), collection_name(*kind)))
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(
|
||||
Block::bordered()
|
||||
@@ -1901,7 +2138,11 @@ impl Browser {
|
||||
);
|
||||
} else if let Some(scroll) = self.help {
|
||||
let mut lines = vec![
|
||||
Line::from("Miruro catalog → series → actual episodes").bold(),
|
||||
Line::from(format!(
|
||||
"{} catalog → series → episodes · sources checked separately",
|
||||
self.provider.name()
|
||||
))
|
||||
.bold(),
|
||||
Line::from(
|
||||
"Terebi resolves episode URLs, sources, subtitles and playback metadata.",
|
||||
),
|
||||
@@ -1913,7 +2154,7 @@ impl Browser {
|
||||
}));
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = Block::bordered()
|
||||
.title(" Miruro keyboard help ")
|
||||
.title(format!(" {} keyboard help ", self.provider.name()))
|
||||
.border_style(ui::ACCENT);
|
||||
let inner = block.inner(popup).inner(Margin::new(1, 0));
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
|
||||
@@ -2030,12 +2271,17 @@ fn library_shows(
|
||||
shows
|
||||
}
|
||||
|
||||
fn collection_videos(state: &State, kind: CollectionKind, filter: WatchFilter) -> Vec<Video> {
|
||||
fn collection_videos(
|
||||
state: &State,
|
||||
kind: CollectionKind,
|
||||
filter: WatchFilter,
|
||||
provider: Provider,
|
||||
) -> Vec<Video> {
|
||||
state
|
||||
.videos(kind)
|
||||
.iter()
|
||||
.filter(|video| {
|
||||
Identity::from_key(&video.id).is_ok()
|
||||
provider.includes(&video.id)
|
||||
&& (!matches!(kind, CollectionKind::WatchLater)
|
||||
|| filter.includes(state.is_watched(&video.id)))
|
||||
})
|
||||
@@ -2101,6 +2347,22 @@ fn media_summary(media: &Media) -> String {
|
||||
if let Some(count) = media.metadata.get("episode_count").and_then(Value::as_u64) {
|
||||
parts.push(format!("{count} planned"));
|
||||
}
|
||||
if miruro_browser::media_provider(media) == Provider::AniList {
|
||||
parts.push(
|
||||
match media.metadata.get("catalog_count").and_then(Value::as_u64) {
|
||||
Some(count) => format!("{count} catalog/airing numbers"),
|
||||
None => "catalog/airing count unknown".into(),
|
||||
},
|
||||
);
|
||||
if media
|
||||
.metadata
|
||||
.get("episode_count")
|
||||
.is_none_or(Value::is_null)
|
||||
{
|
||||
parts.push("total unknown".into());
|
||||
}
|
||||
parts.push("catalog numbers · sub sources checked separately".into());
|
||||
}
|
||||
for (field, label) in [
|
||||
("raw", "raw listed"),
|
||||
("sub", "sub listed"),
|
||||
@@ -2171,7 +2433,20 @@ fn media_details(media: &Media) -> String {
|
||||
}
|
||||
|
||||
impl Browser {
|
||||
pub(crate) fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<Option<bool>> {
|
||||
pub(crate) fn run(
|
||||
&mut self,
|
||||
terminal: &mut DefaultTerminal,
|
||||
) -> io::Result<Option<crate::BrowseProvider>> {
|
||||
let result = self.run_inner(terminal);
|
||||
self.source_checks.cancel();
|
||||
self.source_visible.clear();
|
||||
result
|
||||
}
|
||||
fn run_inner(
|
||||
&mut self,
|
||||
terminal: &mut DefaultTerminal,
|
||||
) -> io::Result<Option<crate::BrowseProvider>> {
|
||||
self.state.select_catalog(self.provider);
|
||||
struct RestoreTerminalInput;
|
||||
impl Drop for RestoreTerminalInput {
|
||||
fn drop(&mut self) {
|
||||
@@ -2193,13 +2468,24 @@ impl Browser {
|
||||
loop {
|
||||
self.update();
|
||||
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
|
||||
self.update_source_checks(Instant::now());
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if self.config.keys.resolve(self.context(), key)
|
||||
== Some(Action::ProviderChoose)
|
||||
{
|
||||
return crate::choose_provider(terminal, &self.config.keys, Some(true));
|
||||
self.source_checks.cancel();
|
||||
self.source_visible.clear();
|
||||
return crate::choose_provider(
|
||||
terminal,
|
||||
&self.config.keys,
|
||||
Some(if self.provider == Provider::AniList {
|
||||
crate::BrowseProvider::AniList
|
||||
} else {
|
||||
crate::BrowseProvider::Miruro
|
||||
}),
|
||||
);
|
||||
}
|
||||
if self.key(key) {
|
||||
return Ok(None);
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Session-only availability checks. Never retain resolved media or provider diagnostics.
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::OsStr,
|
||||
io::{self, Read},
|
||||
process::{Child, ChildStdout, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use tubular::anime::{Identity, Provider};
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const OUTPUT_LIMIT: usize = 4096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(super) enum Status {
|
||||
Available,
|
||||
Unavailable,
|
||||
Failed,
|
||||
}
|
||||
impl Status {
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Available => "ZokoAnime · sub",
|
||||
Self::Unavailable => "No source found",
|
||||
Self::Failed => "Source check failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Reply {
|
||||
version: u32,
|
||||
provider: String,
|
||||
track: String,
|
||||
status: Status,
|
||||
}
|
||||
fn decode(bytes: &[u8], success: bool) -> Status {
|
||||
if !success || bytes.len() > OUTPUT_LIMIT {
|
||||
return Status::Failed;
|
||||
}
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Status::Failed;
|
||||
};
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
if text.contains(['\n', '\r']) {
|
||||
return Status::Failed;
|
||||
}
|
||||
match serde_json::from_str::<Reply>(text) {
|
||||
Ok(reply)
|
||||
if reply.version == 1 && reply.provider == "zokoanime" && reply.track == "sub" =>
|
||||
{
|
||||
reply.status
|
||||
}
|
||||
_ => Status::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
child: Child,
|
||||
stdout: ChildStdout,
|
||||
bytes: Vec<u8>,
|
||||
deadline: Instant,
|
||||
eof: bool,
|
||||
}
|
||||
impl Job {
|
||||
fn start(player: &OsStr, key: &str, now: Instant) -> io::Result<Self> {
|
||||
let identity = Identity::from_key(key)?;
|
||||
if identity.provider != Provider::AniList.key() {
|
||||
return Err(io::Error::other("Unsupported source check"));
|
||||
}
|
||||
let mut command = crate::youtube::isolated_command(player);
|
||||
command.arg("--check-source").arg(identity.watch_url()?);
|
||||
Self::spawn(&mut command, now)
|
||||
}
|
||||
fn spawn(command: &mut std::process::Command, now: Instant) -> io::Result<Self> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let mut job = Self {
|
||||
child,
|
||||
stdout,
|
||||
bytes: Vec::new(),
|
||||
deadline: now + TIMEOUT,
|
||||
eof: false,
|
||||
};
|
||||
job.nonblocking()?;
|
||||
Ok(job)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn nonblocking(&mut self) -> io::Result<()> {
|
||||
use std::os::fd::AsRawFd;
|
||||
let fd = self.stdout.as_raw_fd();
|
||||
// SAFETY: stdout owns this live descriptor; preserve the existing flags.
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn nonblocking(&mut self) -> io::Result<()> {
|
||||
Err(io::Error::other(
|
||||
"Source checks require nonblocking process pipes",
|
||||
))
|
||||
}
|
||||
fn poll(&mut self, now: Instant) -> Option<Status> {
|
||||
if now >= self.deadline {
|
||||
return Some(Status::Failed);
|
||||
}
|
||||
// Each poll consumes at most the protocol limit plus one byte, even from a noisy CLI.
|
||||
let mut buffer = [0; OUTPUT_LIMIT + 1];
|
||||
while !self.eof {
|
||||
let remaining = OUTPUT_LIMIT + 1 - self.bytes.len();
|
||||
match self.stdout.read(&mut buffer[..remaining]) {
|
||||
Ok(0) => self.eof = true,
|
||||
Ok(count) => {
|
||||
self.bytes.extend_from_slice(&buffer[..count]);
|
||||
if self.bytes.len() > OUTPUT_LIMIT {
|
||||
return Some(Status::Failed);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => break,
|
||||
Err(_) => return Some(Status::Failed),
|
||||
}
|
||||
}
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(status)) if self.eof => Some(decode(&self.bytes, status.success())),
|
||||
Ok(_) => None,
|
||||
Err(_) => Some(Status::Failed),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Job {
|
||||
fn drop(&mut self) {
|
||||
// The isolated group may still have descendants holding stdout after the leader exits.
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(-(self.child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
struct Active {
|
||||
generation: u64,
|
||||
key: String,
|
||||
job: Job,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Checks {
|
||||
cache: BTreeMap<String, Status>,
|
||||
active: Option<Active>,
|
||||
}
|
||||
impl Checks {
|
||||
pub(super) fn cancel(&mut self) {
|
||||
self.active = None;
|
||||
}
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.cancel();
|
||||
self.cache.clear();
|
||||
}
|
||||
pub(super) fn status(&self, key: &str) -> Option<Status> {
|
||||
self.cache.get(key).copied()
|
||||
}
|
||||
pub(super) fn label(&self, key: &str) -> &'static str {
|
||||
if let Some(status) = self.status(key) {
|
||||
status.label()
|
||||
} else if self.active.as_ref().is_some_and(|active| active.key == key) {
|
||||
"Checking source…"
|
||||
} else {
|
||||
"Source check pending"
|
||||
}
|
||||
}
|
||||
fn next<'a>(&self, visible: &'a [String], selected: Option<&'a str>) -> Option<&'a str> {
|
||||
selected
|
||||
.filter(|key| {
|
||||
visible.iter().any(|item| item.as_str() == *key) && !self.cache.contains_key(*key)
|
||||
})
|
||||
.or_else(|| {
|
||||
visible
|
||||
.iter()
|
||||
.find(|key| !self.cache.contains_key(*key))
|
||||
.map(String::as_str)
|
||||
})
|
||||
}
|
||||
fn finish(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
result_generation: u64,
|
||||
key: String,
|
||||
status: Status,
|
||||
visible: &[String],
|
||||
) {
|
||||
if generation == result_generation && visible.contains(&key) {
|
||||
self.cache.insert(key, status);
|
||||
}
|
||||
}
|
||||
pub(super) fn update(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
visible: &[String],
|
||||
selected: Option<&str>,
|
||||
player: &OsStr,
|
||||
now: Instant,
|
||||
) {
|
||||
if self
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.generation != generation || !visible.contains(&active.key))
|
||||
{
|
||||
self.cancel();
|
||||
}
|
||||
// Cursor moves do not starve a check that is still visible. New selection gets the next turn.
|
||||
if let Some(status) = self.active.as_mut().and_then(|active| active.job.poll(now)) {
|
||||
let active = self.active.take().expect("completed check");
|
||||
self.finish(generation, active.generation, active.key, status, visible);
|
||||
}
|
||||
if self.active.is_none()
|
||||
&& let Some(key) = self.next(visible, selected)
|
||||
{
|
||||
let key = key.to_owned();
|
||||
match Job::start(player, &key, now) {
|
||||
Ok(job) => {
|
||||
self.active = Some(Active {
|
||||
generation,
|
||||
key,
|
||||
job,
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
self.cache.insert(key, Status::Failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn clock() -> Instant {
|
||||
// All transitions derive from this fixed injected instant; no sleeps or wall-clock assertions.
|
||||
*std::sync::LazyLock::force(&CLOCK)
|
||||
}
|
||||
static CLOCK: std::sync::LazyLock<Instant> = std::sync::LazyLock::new(Instant::now);
|
||||
#[test]
|
||||
fn protocol_distinguishes_missing_from_failure_and_rejects_other_output() {
|
||||
for (status, expected) in [
|
||||
("available", Status::Available),
|
||||
("unavailable", Status::Unavailable),
|
||||
("failed", Status::Failed),
|
||||
] {
|
||||
let text = format!(
|
||||
r#"{{"version":1,"provider":"zokoanime","track":"sub","status":"{status}"}}"#
|
||||
);
|
||||
assert_eq!(decode(text.as_bytes(), true), expected);
|
||||
assert_eq!(decode(text.as_bytes(), false), Status::Failed);
|
||||
}
|
||||
for text in [
|
||||
"",
|
||||
"old CLI help",
|
||||
r#"{"version":2,"provider":"zokoanime","track":"sub","status":"available"}"#,
|
||||
r#"{"version":1,"provider":"other","track":"sub","status":"available"}"#,
|
||||
r#"{"version":1,"provider":"zokoanime","track":"dub","status":"available"}"#,
|
||||
] {
|
||||
assert_eq!(decode(text.as_bytes(), true), Status::Failed);
|
||||
}
|
||||
assert_eq!(decode(&vec![b' '; OUTPUT_LIMIT + 1], true), Status::Failed);
|
||||
assert_eq!(decode(b"{}\n{}", true), Status::Failed);
|
||||
}
|
||||
#[test]
|
||||
fn selected_then_visible_and_no_retry_until_refresh() {
|
||||
let mut checks = Checks::default();
|
||||
let visible = vec!["anilist:1:3".into(), "anilist:1:4".into()];
|
||||
assert_eq!(
|
||||
checks.next(&visible, Some("anilist:1:4")),
|
||||
Some("anilist:1:4")
|
||||
);
|
||||
assert_eq!(
|
||||
checks.next(&visible, Some("anilist:1:99")),
|
||||
Some("anilist:1:3")
|
||||
);
|
||||
for key in &visible {
|
||||
checks.finish(1, 1, key.clone(), Status::Failed, &visible);
|
||||
}
|
||||
assert_eq!(checks.next(&visible, Some("anilist:1:4")), None);
|
||||
checks.clear();
|
||||
assert_eq!(
|
||||
checks.next(&visible, Some("anilist:1:4")),
|
||||
Some("anilist:1:4")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn late_generation_or_offscreen_results_do_not_enter_cache() {
|
||||
let mut checks = Checks::default();
|
||||
let visible: Vec<String> = vec!["anilist:1:3".into()];
|
||||
checks.finish(2, 1, visible[0].clone(), Status::Available, &visible);
|
||||
checks.finish(2, 2, "anilist:1:2".into(), Status::Available, &visible);
|
||||
assert!(checks.cache.is_empty());
|
||||
checks.finish(2, 2, visible[0].clone(), Status::Unavailable, &visible);
|
||||
assert_eq!(checks.cache.get(&visible[0]), Some(&Status::Unavailable));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn waiting_job() -> Job {
|
||||
let mut command = crate::youtube::isolated_command(OsStr::new("/bin/sh"));
|
||||
command.args(["-c", "kill -STOP $$"]);
|
||||
Job::spawn(&mut command, clock()).unwrap()
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn injected_timeout_and_drop_reap_the_child() {
|
||||
let mut job = waiting_job();
|
||||
let pid = job.child.id();
|
||||
assert_eq!(job.poll(clock() + TIMEOUT), Some(Status::Failed));
|
||||
drop(job);
|
||||
let mut status = 0;
|
||||
assert_eq!(
|
||||
unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(
|
||||
io::Error::last_os_error().raw_os_error(),
|
||||
Some(libc::ECHILD)
|
||||
);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cancel_offscreen_or_generation_but_not_visible_deselection() {
|
||||
let visible: Vec<String> = vec!["anilist:1:3".into(), "anilist:1:4".into()];
|
||||
let mut checks = Checks {
|
||||
active: Some(Active {
|
||||
generation: 7,
|
||||
key: visible[0].clone(),
|
||||
job: waiting_job(),
|
||||
}),
|
||||
..Checks::default()
|
||||
};
|
||||
checks.update(
|
||||
7,
|
||||
&visible,
|
||||
Some(&visible[1]),
|
||||
OsStr::new("/missing-player"),
|
||||
clock(),
|
||||
);
|
||||
assert_eq!(checks.active.as_ref().unwrap().key, visible[0]);
|
||||
checks.update(7, &[], None, OsStr::new("/missing-player"), clock());
|
||||
assert!(checks.active.is_none());
|
||||
assert!(checks.cache.is_empty());
|
||||
checks.active = Some(Active {
|
||||
generation: 7,
|
||||
key: visible[0].clone(),
|
||||
job: waiting_job(),
|
||||
});
|
||||
checks.update(8, &[], None, OsStr::new("/missing-player"), clock());
|
||||
assert!(checks.active.is_none());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn old_cli_exit_without_protocol_is_failed_without_blocking_playback_contract() {
|
||||
let mut job = Job::start(OsStr::new("/usr/bin/true"), "anilist:1:1", clock()).unwrap();
|
||||
job.child.wait().unwrap();
|
||||
assert_eq!(job.poll(clock()), Some(Status::Failed));
|
||||
assert!(Job::start(OsStr::new("/usr/bin/true"), "miruro:1:1", clock()).is_err());
|
||||
assert!(Job::start(OsStr::new("/missing-player"), "anilist:1:1", clock()).is_err());
|
||||
}
|
||||
}
|
||||
+62
-27
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs::{self, File, OpenOptions, ReadDir},
|
||||
io::{self, Read, Write},
|
||||
io::{self, Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
@@ -10,7 +10,7 @@ use serde::Deserialize;
|
||||
use toml_edit::{Array, ArrayOfTables, DocumentMut, Table, value};
|
||||
|
||||
use crate::youtube::{Video, valid_video_id, validate_video};
|
||||
use tubular::miruro::Identity;
|
||||
use tubular::anime::{Identity, Provider};
|
||||
|
||||
const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const MAX_LINE: usize = 16 * 1024;
|
||||
@@ -134,6 +134,9 @@ pub struct State {
|
||||
watch_state: StoredFile,
|
||||
library: StoredFile,
|
||||
saved_shows: Vec<SavedShow>,
|
||||
other_library: StoredFile,
|
||||
other_shows: Vec<SavedShow>,
|
||||
catalog: Provider,
|
||||
watched: BTreeSet<String>,
|
||||
pending: BTreeSet<String>,
|
||||
sessions: PathBuf,
|
||||
@@ -158,6 +161,20 @@ impl State {
|
||||
let (library, document) = StoredFile::open(parent.join("miruro-library.toml"))?;
|
||||
let saved_shows = load_shows(&document)
|
||||
.map_err(|e| invalid(format!("{}: {e}", library.path.display())))?;
|
||||
let (other_library, document) = StoredFile::open(parent.join("anilist-library.toml"))?;
|
||||
let other_shows = load_shows(&document)
|
||||
.map_err(|e| invalid(format!("{}: {e}", other_library.path.display())))?;
|
||||
if saved_shows
|
||||
.iter()
|
||||
.any(|show| crate::miruro_browser::media_provider(&show.media) != Provider::Miruro)
|
||||
|| other_shows
|
||||
.iter()
|
||||
.any(|show| crate::miruro_browser::media_provider(&show.media) != Provider::AniList)
|
||||
{
|
||||
return Err(invalid(
|
||||
"Saved-show library contains a different catalog provider",
|
||||
));
|
||||
}
|
||||
let completed: Vec<_> = watching.ids.intersection(&watched).cloned().collect();
|
||||
if !completed.is_empty() {
|
||||
watching.remove(&completed)?;
|
||||
@@ -169,6 +186,9 @@ impl State {
|
||||
watch_state,
|
||||
library,
|
||||
saved_shows,
|
||||
other_library,
|
||||
other_shows,
|
||||
catalog: Provider::Miruro,
|
||||
watched,
|
||||
pending: BTreeSet::new(),
|
||||
sessions: parent.join("playback-sessions"),
|
||||
@@ -179,6 +199,14 @@ impl State {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select_catalog(&mut self, provider: Provider) {
|
||||
if self.catalog != provider {
|
||||
std::mem::swap(&mut self.library, &mut self.other_library);
|
||||
std::mem::swap(&mut self.saved_shows, &mut self.other_shows);
|
||||
self.catalog = provider;
|
||||
}
|
||||
}
|
||||
|
||||
fn collection(&self, kind: CollectionKind) -> &Collection {
|
||||
match kind {
|
||||
CollectionKind::WatchLater => &self.watch_later,
|
||||
@@ -215,6 +243,9 @@ impl State {
|
||||
tags: &BTreeSet<String>,
|
||||
) -> io::Result<()> {
|
||||
validate_show(media)?;
|
||||
if crate::miruro_browser::media_provider(media) != self.catalog {
|
||||
return Err(invalid("Cannot save a show into another catalog's library"));
|
||||
}
|
||||
if tags.len() > MAX_SHOW_TAGS {
|
||||
return Err(invalid("A saved show cannot have more than 256 tags"));
|
||||
}
|
||||
@@ -227,7 +258,7 @@ impl State {
|
||||
.iter()
|
||||
.position(|show| show.media.id == media.id);
|
||||
if index.is_none() && self.saved_shows.len() >= MAX_SHOWS {
|
||||
return Err(invalid("Miruro library exceeds the 4096 show limit"));
|
||||
return Err(invalid("Anime library exceeds the 4096 show limit"));
|
||||
}
|
||||
let show = SavedShow {
|
||||
media: media.clone(),
|
||||
@@ -254,7 +285,7 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn remove_show(&mut self, id: &str) -> io::Result<()> {
|
||||
Identity::new(id.into(), 1)?;
|
||||
self.catalog.identity(id.into(), 1)?;
|
||||
if !self.saved_shows.iter().any(|show| show.media.id == id) {
|
||||
return check_original(&self.library.path, self.library.original.as_deref());
|
||||
}
|
||||
@@ -502,7 +533,7 @@ impl State {
|
||||
}
|
||||
|
||||
fn validate_show(media: &crate::miruro_browser::Media) -> io::Result<()> {
|
||||
Identity::new(media.id.clone(), 1)?;
|
||||
crate::miruro_browser::media_provider(media).identity(media.id.clone(), 1)?;
|
||||
if media.title.len() > MAX_LINE
|
||||
|| media.title.trim().is_empty()
|
||||
|| media.title.chars().any(char::is_control)
|
||||
@@ -528,7 +559,7 @@ fn load_shows(document: &DocumentMut) -> io::Result<Vec<SavedShow>> {
|
||||
.iter()
|
||||
.any(|(key, _)| !matches!(key, "version" | "shows"))
|
||||
{
|
||||
return Err(invalid("Unknown Miruro library field"));
|
||||
return Err(invalid("Unknown anime library field"));
|
||||
}
|
||||
let Some(item) = document.get("shows") else {
|
||||
return Ok(Vec::new());
|
||||
@@ -540,7 +571,7 @@ fn load_shows(document: &DocumentMut) -> io::Result<Vec<SavedShow>> {
|
||||
.as_array_of_tables()
|
||||
.ok_or_else(|| invalid("shows must be an array of tables"))?;
|
||||
if tables.len() > MAX_SHOWS {
|
||||
return Err(invalid("Miruro library exceeds the 4096 show limit"));
|
||||
return Err(invalid("Anime library exceeds the 4096 show limit"));
|
||||
}
|
||||
let mut shows = Vec::new();
|
||||
let mut ids = BTreeSet::new();
|
||||
@@ -648,19 +679,24 @@ fn validate_snapshot(video: &mut Video, require_channel: bool) -> io::Result<()>
|
||||
));
|
||||
}
|
||||
}
|
||||
if video.id.starts_with("miruro:") {
|
||||
Identity::from_key(&video.id)?;
|
||||
if video.id.starts_with("miruro:") || video.id.starts_with("anilist:") {
|
||||
let identity = Identity::from_key(&video.id)?;
|
||||
let expected_channel = if identity.provider == "anilist" {
|
||||
"AniList"
|
||||
} else {
|
||||
"Miruro"
|
||||
};
|
||||
if video.title.trim().is_empty() || video.title.chars().any(char::is_control) {
|
||||
return Err(invalid(
|
||||
"Miruro episode title must be nonempty and contain no controls",
|
||||
"Anime episode title must be nonempty and contain no controls",
|
||||
));
|
||||
}
|
||||
if video.channel.as_deref() != Some("Miruro")
|
||||
if video.channel.as_deref() != Some(expected_channel)
|
||||
|| video.channel_id.is_some()
|
||||
|| video.uploader_id.is_some()
|
||||
{
|
||||
return Err(invalid(
|
||||
"Miruro episodes must not contain YouTube channel identities",
|
||||
"Anime episode channel must match its catalog, without YouTube channel identities",
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
@@ -983,7 +1019,7 @@ pub(crate) fn register_identity(progress_file: &Path, id: &str) -> io::Result<()
|
||||
#[serde(untagged)]
|
||||
enum ProgressRecord {
|
||||
YouTube(YouTubeProgress),
|
||||
Miruro(MiruroProgress),
|
||||
Anime(AnimeProgress),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -998,7 +1034,7 @@ struct YouTubeProgress {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct MiruroProgress {
|
||||
struct AnimeProgress {
|
||||
version: u32,
|
||||
event: String,
|
||||
media_identity: Identity,
|
||||
@@ -1015,7 +1051,7 @@ struct ProgressStream {
|
||||
seen: bool,
|
||||
status_edited: bool,
|
||||
started: bool,
|
||||
file: Option<File>,
|
||||
offset: u64,
|
||||
partial: Vec<u8>,
|
||||
discarding: bool,
|
||||
failed: bool,
|
||||
@@ -1032,7 +1068,7 @@ impl ProgressStream {
|
||||
seen: false,
|
||||
status_edited: false,
|
||||
started: false,
|
||||
file: None,
|
||||
offset: 0,
|
||||
partial: Vec::new(),
|
||||
discarding: false,
|
||||
failed: false,
|
||||
@@ -1067,9 +1103,7 @@ impl ProgressStream {
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound && valid_video_id(id) => None,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
return Err(invalid(
|
||||
"Miruro sessions require their bound video metadata",
|
||||
));
|
||||
return Err(invalid("Anime sessions require their bound video metadata"));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
@@ -1150,12 +1184,13 @@ impl ProgressStream {
|
||||
pending: &mut BTreeSet<String>,
|
||||
watched: &BTreeSet<String>,
|
||||
) -> io::Result<Option<io::Error>> {
|
||||
if self.file.is_none() {
|
||||
self.prepare()?;
|
||||
self.file = Some(regular_file(&self.path, true)?);
|
||||
}
|
||||
// Keep a cursor, not a descriptor, for each retained playback session.
|
||||
let mut file = regular_file(&self.path, true)?;
|
||||
file.seek(SeekFrom::Start(self.offset))?;
|
||||
let mut buffer = [0; STREAM_BYTES];
|
||||
let size = self.file.as_mut().unwrap().read(&mut buffer)?;
|
||||
let size = file.read(&mut buffer)?;
|
||||
self.offset += size as u64;
|
||||
let mut error = None;
|
||||
for &byte in &buffer[..size] {
|
||||
if byte == b'\n' {
|
||||
@@ -1198,7 +1233,7 @@ impl ProgressStream {
|
||||
) -> io::Result<()> {
|
||||
let record: ProgressRecord =
|
||||
serde_json::from_slice(&self.partial).map_err(|e| self.record_error(e))?;
|
||||
let miruro_key;
|
||||
let anime_key;
|
||||
let (id, event, position, duration, eligible) = match &record {
|
||||
ProgressRecord::YouTube(record) if record.version == 1 => {
|
||||
let id = record
|
||||
@@ -1214,14 +1249,14 @@ impl ProgressStream {
|
||||
record.completion_eligible,
|
||||
)
|
||||
}
|
||||
ProgressRecord::Miruro(record) if record.version == 2 => {
|
||||
ProgressRecord::Anime(record) if record.version == 2 => {
|
||||
record
|
||||
.media_identity
|
||||
.validate()
|
||||
.map_err(|e| self.record_error(e))?;
|
||||
miruro_key = record.media_identity.key();
|
||||
anime_key = record.media_identity.key();
|
||||
(
|
||||
miruro_key.as_str(),
|
||||
anime_key.as_str(),
|
||||
record.event.as_str(),
|
||||
record.position_seconds,
|
||||
record.duration_seconds,
|
||||
|
||||
+136
-3
@@ -132,6 +132,12 @@ pub struct Page {
|
||||
pub feed_progress: Vec<FeedProgress>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Playlist {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct Video {
|
||||
pub id: String,
|
||||
@@ -192,6 +198,97 @@ pub fn search_results(output: &str) -> Result<Page, String> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn channel_playlists(output: &str) -> Result<Vec<Playlist>, String> {
|
||||
let results: SearchResults = serde_json::from_str(output)
|
||||
.map_err(|error| format!("Invalid channel playlists response: {error}"))?;
|
||||
let mut ids = BTreeSet::new();
|
||||
Ok(results
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let mut playlist: Playlist = serde_json::from_str(entry.get()).ok()?;
|
||||
if !valid_playlist_id(&playlist.id) {
|
||||
return None;
|
||||
}
|
||||
playlist.title.retain(|c| !c.is_control());
|
||||
if playlist.title.trim().is_empty() || !ids.insert(playlist.id.clone()) {
|
||||
return None;
|
||||
}
|
||||
Some(playlist)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn playlist_results(output: &str) -> Result<Page, String> {
|
||||
let results: SearchResults = serde_json::from_str(output)
|
||||
.map_err(|error| format!("Invalid playlist response: {error}"))?;
|
||||
if results
|
||||
.channel_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| !valid_channel_id(id))
|
||||
|| results
|
||||
.uploader_id
|
||||
.as_deref()
|
||||
.is_some_and(|handle| handle.chars().any(char::is_control))
|
||||
{
|
||||
return Err("Playlist returned invalid YouTube creator metadata".into());
|
||||
}
|
||||
let has_more = results.entries.len() >= PAGE_SIZE;
|
||||
let videos = results
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let mut video = listed_video(entry)?;
|
||||
// A playlist owner is not necessarily the creator of each video.
|
||||
let same_creator = video
|
||||
.channel_id
|
||||
.as_ref()
|
||||
.is_none_or(|id| Some(id) == results.channel_id.as_ref())
|
||||
&& video
|
||||
.uploader_id
|
||||
.as_ref()
|
||||
.is_none_or(|id| Some(id) == results.uploader_id.as_ref());
|
||||
if same_creator && (video.channel_id.is_some() || video.uploader_id.is_some()) {
|
||||
if video.channel_id.is_none() {
|
||||
video.channel_id = results.channel_id.clone();
|
||||
}
|
||||
if video.uploader_id.is_none() {
|
||||
video.uploader_id = results.uploader_id.clone();
|
||||
}
|
||||
if video.channel.is_none() {
|
||||
video.channel = results.channel.clone();
|
||||
}
|
||||
}
|
||||
validate_video(&mut video).ok()?;
|
||||
Some(video)
|
||||
})
|
||||
.collect();
|
||||
Ok(Page {
|
||||
videos,
|
||||
has_more,
|
||||
feed_progress: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_playlist_id(id: &str) -> bool {
|
||||
if !id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Match yt-dlp's YouTube playlist families, including music releases.
|
||||
matches!(id, "RDMM" | "WL" | "LL" | "LM")
|
||||
|| [
|
||||
"PL", "LL", "EC", "UU", "FL", "RD", "UL", "TL", "PU", "OLAK5uy_",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| {
|
||||
id.strip_prefix(*prefix)
|
||||
.is_some_and(|suffix| suffix.len() >= 10)
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_channel_id(id: &str) -> bool {
|
||||
id.len() == 24
|
||||
&& id.starts_with("UC")
|
||||
@@ -489,6 +586,36 @@ impl Job {
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn channel_playlists(channel: &str) -> io::Result<Self> {
|
||||
let channel = crate::config::normalize_channel(channel)?;
|
||||
Self::spawn(extractor().args([
|
||||
"--flat-playlist",
|
||||
"--dump-single-json",
|
||||
&format!("{channel}/playlists"),
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn playlist(id: &str, start: usize) -> io::Result<Self> {
|
||||
if !valid_playlist_id(id) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Invalid YouTube playlist ID",
|
||||
));
|
||||
}
|
||||
let end = page_end(start)?;
|
||||
Self::spawn(extractor().args([
|
||||
"--flat-playlist",
|
||||
"--playlist-start",
|
||||
&start.to_string(),
|
||||
"--playlist-end",
|
||||
&end.to_string(),
|
||||
"--extractor-args",
|
||||
"youtubetab:approximate_date",
|
||||
"--dump-single-json",
|
||||
&format!("https://www.youtube.com/playlist?list={id}"),
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn feed(channels: &[String], requests: &[FeedRequest], query: &str) -> io::Result<Self> {
|
||||
let mut job = Self::spawn_all(feed_commands(channels, requests, query)?)?;
|
||||
if query.is_empty() {
|
||||
@@ -812,7 +939,7 @@ fn read_thumbnail(pipe: impl Read) -> io::Result<Vec<u8>> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn isolated_command(program: &OsStr) -> Command {
|
||||
pub(crate) fn isolated_command(program: &OsStr) -> Command {
|
||||
let mut command = Command::new(program);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -824,7 +951,13 @@ fn isolated_command(program: &OsStr) -> Command {
|
||||
|
||||
fn extractor() -> Command {
|
||||
let mut command = isolated_command(OsStr::new("yt-dlp"));
|
||||
command.args(["--ignore-config", "--no-warnings", "--socket-timeout", "15"]);
|
||||
command.args([
|
||||
"--ignore-config",
|
||||
"--force-ipv4",
|
||||
"--no-warnings",
|
||||
"--socket-timeout",
|
||||
"15",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
@@ -867,7 +1000,7 @@ pub fn video_url(id: &str) -> io::Result<String> {
|
||||
if valid_video_id(id) {
|
||||
Ok(format!("https://www.youtube.com/watch?v={id}"))
|
||||
} else {
|
||||
tubular::miruro::Identity::from_key(id)
|
||||
tubular::anime::Identity::from_key(id)
|
||||
.and_then(|identity| identity.watch_url())
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
|
||||
}
|
||||
|
||||
+105
-19
@@ -27,32 +27,36 @@ fn startup_provider_selection_preserves_query_and_handles_boundaries_and_cancel(
|
||||
provider_selection(&mut selected, Some(Action::SelectorPrevious)),
|
||||
Continue(())
|
||||
);
|
||||
for (action, miruro) in [(Action::SelectorOpen, false), (Action::SelectorNext, true)] {
|
||||
if action == Action::SelectorNext {
|
||||
assert_eq!(
|
||||
provider_selection(&mut selected, Some(action)),
|
||||
Continue(())
|
||||
);
|
||||
assert_eq!(
|
||||
provider_selection(&mut selected, Some(action)),
|
||||
Continue(())
|
||||
);
|
||||
}
|
||||
for (index, provider) in [
|
||||
BrowseProvider::YouTube,
|
||||
BrowseProvider::AniList,
|
||||
BrowseProvider::Miruro,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
selected = index;
|
||||
let Break(Some(choice)) = provider_selection(&mut selected, Some(Action::SelectorOpen))
|
||||
else {
|
||||
panic!("Selected provider must open");
|
||||
};
|
||||
assert_eq!(choice, miruro);
|
||||
assert_eq!(choice, provider);
|
||||
let mut options = Options::parse_from(["tubular", "initial query"]);
|
||||
options.miruro = choice;
|
||||
options.miruro = choice == BrowseProvider::Miruro;
|
||||
options.anilist = choice == BrowseProvider::AniList;
|
||||
assert_eq!(options.query.as_deref(), Some("initial query"));
|
||||
assert_eq!(options.miruro, miruro);
|
||||
if !miruro {
|
||||
if choice == BrowseProvider::YouTube {
|
||||
let app = App::new(options, PathBuf::new(), config::Config::default());
|
||||
assert_eq!(app.views[0].query, "initial query");
|
||||
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
provider_selection(&mut selected, Some(Action::SelectorNext)),
|
||||
Continue(())
|
||||
);
|
||||
assert_eq!(selected, PROVIDERS.len() - 1);
|
||||
assert!(Options::try_parse_from(["tubular", "--miruro", "--anilist"]).is_err());
|
||||
for action in [Action::SelectorClose, Action::Quit, Action::Interrupt] {
|
||||
assert_eq!(provider_selection(&mut selected, Some(action)), Break(None));
|
||||
}
|
||||
@@ -68,7 +72,7 @@ fn app(args: &[&str]) -> App {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
|
||||
fn playback_checks_preserve_check_order_across_feed_sort_and_clear_on_replacement() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.view_index = 1;
|
||||
app.feed_channels = vec!["https://www.youtube.com/@fixture".into()];
|
||||
@@ -87,7 +91,7 @@ fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
|
||||
["jNQXAC9IVRw", "rQ_J9WH6CGk"],
|
||||
);
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
@@ -96,6 +100,16 @@ fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
|
||||
);
|
||||
let view = &mut app.views[1];
|
||||
assert_eq!(view.selection.selected(), Some(1));
|
||||
assert_eq!(
|
||||
view.playback_videos()
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["jNQXAC9IVRw", "rQ_J9WH6CGk"],
|
||||
);
|
||||
view.selection.select(Some(2));
|
||||
view.toggle_checked();
|
||||
view.toggle_checked();
|
||||
assert_eq!(
|
||||
view.playback_videos()
|
||||
.iter()
|
||||
@@ -251,7 +265,7 @@ fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hove
|
||||
app.options.player = "/nonexistent/tubular-test-player".into();
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(app.player_launch.is_none());
|
||||
assert!(app.views[0].checked.contains("rQ_J9WH6CGk"));
|
||||
assert!(app.views[0].checked.iter().any(|id| id == "rQ_J9WH6CGk"));
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
||||
app.options.player = "/usr/bin/true".into();
|
||||
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
@@ -3735,6 +3749,78 @@ fn back_history_uses_source_identity_after_config_reorders_or_removes_views() {
|
||||
assert!(app.job.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_playlists_filter_open_in_provider_order_and_retain_history_and_config() {
|
||||
let mut app = app(&["tubular"]);
|
||||
let press = |app: &mut App, code| {
|
||||
app.key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
};
|
||||
press(&mut app, KeyCode::Char('L'));
|
||||
assert!(app.browser.is_none());
|
||||
let channel = ViewSource::Channel("https://www.youtube.com/@RustVideos".into());
|
||||
let mut view = View::new("@RustVideos".into(), channel.clone());
|
||||
view.loaded = true;
|
||||
app.views.push(view);
|
||||
app.switch_view(app.views.len() - 1);
|
||||
press(&mut app, KeyCode::Char('L'));
|
||||
assert_eq!(app.browser.as_ref().unwrap().kind, BrowserKind::Playlists);
|
||||
app.finish_playlists(Ok(r#"{"entries":[{"id":"PLJ5MFiMfElFA","title":"Conference"},{"id":"PLJ5MFiMfElFB","title":"Tutorials"}]}"#.into()));
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
app.paste("tutor");
|
||||
assert_eq!(
|
||||
app.browser.as_ref().unwrap().selected_choice().unwrap().0,
|
||||
"Tutorials"
|
||||
);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert!(app.browser.is_none());
|
||||
let playlist = ViewSource::Playlist("PLJ5MFiMfElFB".into());
|
||||
assert_eq!(app.views[app.view_index].source, playlist);
|
||||
app.finish_page(Ok(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#.into()));
|
||||
let view = &app.views[app.view_index];
|
||||
assert_eq!(
|
||||
view.videos
|
||||
.iter()
|
||||
.map(|video| video.title.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["First", "Second"]
|
||||
);
|
||||
assert!(!view.has_more);
|
||||
press(&mut app, KeyCode::Char('j'));
|
||||
press(&mut app, KeyCode::Char(' '));
|
||||
assert_eq!(
|
||||
app.views[app.view_index].playback_videos()[0].id,
|
||||
"rQ_J9WH6CGk"
|
||||
);
|
||||
press(&mut app, KeyCode::Char('-'));
|
||||
assert_eq!(app.views[app.view_index].source, channel);
|
||||
press(&mut app, KeyCode::Char('+'));
|
||||
assert_eq!(app.views[app.view_index].source, playlist);
|
||||
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
|
||||
app.apply_config(config::Config {
|
||||
categories: [("Unrelated".into(), Vec::new())].into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(app.views[app.view_index].source, playlist);
|
||||
assert_eq!(app.temporary_view, Some(playlist));
|
||||
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
|
||||
assert!(app.config.subscriptions().is_empty());
|
||||
press(&mut app, KeyCode::Char('-'));
|
||||
press(&mut app, KeyCode::Char('L'));
|
||||
app.finish_playlists(Err("Extractor failed".into()));
|
||||
assert_eq!(
|
||||
app.browser.as_ref().unwrap().error.as_deref(),
|
||||
Some("Extractor failed")
|
||||
);
|
||||
press(&mut app, KeyCode::Esc);
|
||||
assert!(app.browser.is_none());
|
||||
press(&mut app, KeyCode::Char('L'));
|
||||
app.finish_playlists(Ok(r#"{"entries":[]}"#.into()));
|
||||
assert!(app.browser.as_ref().unwrap().choices.is_empty());
|
||||
assert!(app.browser.as_ref().unwrap().error.is_none());
|
||||
press(&mut app, KeyCode::Esc);
|
||||
assert_eq!(app.views[app.view_index].source, channel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
|
||||
let mut app = app(&["tubular"]);
|
||||
@@ -6069,7 +6155,7 @@ fn escape_discards_checks_without_consuming_view_history() {
|
||||
app.views[0].loaded = true;
|
||||
app.views[1].loaded = true;
|
||||
app.switch_view(1);
|
||||
app.views[1].checked.insert("jNQXAC9IVRw".into());
|
||||
app.views[1].checked.push("jNQXAC9IVRw".into());
|
||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(app.views[1].checked.is_empty());
|
||||
assert_eq!(app.view_index, 1);
|
||||
|
||||
+46
-1
@@ -120,6 +120,47 @@ fn provider_choice_is_configurable_and_normal_only() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_playlists_are_configurable_and_normal_only() {
|
||||
let defaults = Bindings::default();
|
||||
let overrides = configured("[keys.channel]\nplaylists = 'alt+l'\n").unwrap();
|
||||
for &context in ALL {
|
||||
let expected = (context == Context::Normal).then_some(Action::ChannelPlaylists);
|
||||
for (code, modifiers) in [
|
||||
(KeyCode::Char('L'), KeyModifiers::NONE),
|
||||
(KeyCode::Char('L'), KeyModifiers::SHIFT),
|
||||
(KeyCode::Char('l'), KeyModifiers::SHIFT),
|
||||
] {
|
||||
assert_eq!(press(&defaults, context, code, modifiers), expected);
|
||||
assert_ne!(
|
||||
press(&overrides, context, code, modifiers),
|
||||
Some(Action::ChannelPlaylists),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
press(&defaults, context, KeyCode::Char('l'), KeyModifiers::NONE),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
press(&overrides, context, KeyCode::Char('l'), KeyModifiers::ALT),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
assert!(default_config().contains("channel.playlists = [\"L\"]"));
|
||||
assert!(defaults.help_text().to_string().lines().any(|line| {
|
||||
line.trim_start().starts_with("L ")
|
||||
&& line.contains("Browse the current channel's playlists")
|
||||
}));
|
||||
assert!(overrides.help_text().to_string().lines().any(|line| {
|
||||
line.trim_start().starts_with("Alt+l ")
|
||||
&& line.contains("Browse the current channel's playlists")
|
||||
}));
|
||||
let collision = configured("[keys.channel]\nplaylists = 'u'\n")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(collision.contains("channel.playlists") && collision.contains("channel.browse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_section_headers_are_bold_in_the_grouped_overview() {
|
||||
use ratatui::{
|
||||
@@ -426,6 +467,9 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
|
||||
(Context::Description, Context::CollectionDescription),
|
||||
] {
|
||||
for &(key, action) in &bindings.contexts[ordinary as usize] {
|
||||
if action == Action::ChannelPlaylists {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
|
||||
Some(action),
|
||||
@@ -434,7 +478,8 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
|
||||
}
|
||||
assert_eq!(
|
||||
bindings.contexts[collection as usize].len(),
|
||||
bindings.contexts[ordinary as usize].len() + 2,
|
||||
bindings.contexts[ordinary as usize].len() + 2
|
||||
- usize::from(ordinary == Context::Normal),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+105
-1
@@ -10,7 +10,9 @@ fn watch_url_preserves_stable_identity_and_validates_mutable_fields() {
|
||||
);
|
||||
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
|
||||
assert_ne!(
|
||||
Identity::new(identity.media_id.clone(), 2).unwrap().key(),
|
||||
Identity::miruro(identity.media_id.clone(), 2)
|
||||
.unwrap()
|
||||
.key(),
|
||||
identity.key()
|
||||
);
|
||||
for key in [
|
||||
@@ -83,3 +85,105 @@ fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() {
|
||||
io::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_failover_remembers_success_and_stops_on_rate_limits_and_bad_requests() {
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::TcpListener,
|
||||
thread,
|
||||
};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
for (path, status, mime, body) in [
|
||||
("/one/catalog?q=anime", 403, "text/html", "blocked"),
|
||||
("/two/catalog?q=anime", 200, "text/html", "protection page"),
|
||||
(
|
||||
"/three/catalog?q=anime",
|
||||
200,
|
||||
"application/json",
|
||||
r#"{"data":[{"id":"series"}]}"#,
|
||||
),
|
||||
("/three/detail", 503, "text/html", "unavailable"),
|
||||
(
|
||||
"/one/detail",
|
||||
200,
|
||||
"text/sveltekit-data",
|
||||
"episode metadata",
|
||||
),
|
||||
("/one/limited", 429, "text/html", "rate limited"),
|
||||
("/one/bad-query", 400, "application/json", "{}"),
|
||||
("/one/exhausted", 403, "text/html", "blocked"),
|
||||
("/two/exhausted", 404, "text/html", "gone"),
|
||||
("/three/exhausted", 502, "text/html", "unavailable"),
|
||||
("/one/recovered", 200, "application/json", r#"{"data":[]}"#),
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(line.trim_end(), format!("GET {path} HTTP/1.1"));
|
||||
loop {
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status} Response\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
).unwrap();
|
||||
}
|
||||
});
|
||||
let bases = [
|
||||
format!("{base}/one"),
|
||||
format!("{base}/two"),
|
||||
format!("{base}/three"),
|
||||
];
|
||||
let mirrors: Vec<_> = bases.iter().map(String::as_str).collect();
|
||||
let client = Client::default();
|
||||
let body = client
|
||||
.get_from(
|
||||
&mirrors,
|
||||
"/catalog",
|
||||
&[("q", "anime")],
|
||||
&["application/json"],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&body).unwrap()["data"][0]["id"],
|
||||
"series"
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.get_from(&mirrors, "/detail", &[], &["text/sveltekit-data"])
|
||||
.unwrap(),
|
||||
"episode metadata"
|
||||
);
|
||||
for (path, status) in [("/limited", 429), ("/bad-query", 400)] {
|
||||
let error = client
|
||||
.get_from(&mirrors, path, &[], &["application/json"])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains(&format!("HTTP {status}")));
|
||||
}
|
||||
let error = client
|
||||
.get_from(&mirrors, "/exhausted", &[], &["application/json"])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("failed on 3/3 mirrors"));
|
||||
assert!(error.to_string().contains("HTTP 502"));
|
||||
let body = client
|
||||
.get_from(&mirrors, "/recovered", &[], &["application/json"])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&body).unwrap()["data"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
@@ -67,3 +67,31 @@ fn catalog_and_actual_episode_numbers_remain_distinct() {
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anilist_catalog_numbers_are_not_planned_totals_or_stream_claims() {
|
||||
let metadata = serde_json::json!({"id":"1","catalog_provider":"anilist","title":{"romaji":"Cowboy Bebop"},"status":"FINISHED","episode_count":26,"catalog_count":26});
|
||||
let series = anilist_series(metadata).unwrap();
|
||||
assert_eq!(series.episodes.len(), 26);
|
||||
assert_eq!(
|
||||
series.episodes.last().unwrap().identity.key(),
|
||||
"anilist:1:26"
|
||||
);
|
||||
let snapshot = series.episodes[0].snapshot(&series.media);
|
||||
assert_eq!(snapshot.channel.as_deref(), Some("AniList"));
|
||||
assert_eq!(
|
||||
crate::youtube::video_url(&snapshot.id).unwrap(),
|
||||
"https://zokoanime.video/stream/ani/1/1/sub"
|
||||
);
|
||||
let ongoing = anilist_series(serde_json::json!({"id":"2","catalog_provider":"anilist","title":{"romaji":"Ongoing"},"status":"RELEASING","episode_count":24,"nextAiringEpisode":{"episode":4},"catalog_count":3})).unwrap();
|
||||
assert_eq!(ongoing.episodes.len(), 3);
|
||||
let unknown = anilist_series(serde_json::json!({"id":"3","catalog_provider":"anilist","title":{"romaji":"Unknown"},"status":"RELEASING","episode_count":null,"catalog_count":null})).unwrap();
|
||||
assert!(unknown.episodes.is_empty());
|
||||
assert!(unknown.media.metadata["catalog_count"].is_null());
|
||||
assert_eq!(
|
||||
anilist_episode(&unknown.media, 42).unwrap().identity.key(),
|
||||
"anilist:3:42"
|
||||
);
|
||||
assert!(anilist_episode(&unknown.media, 0).is_err());
|
||||
assert!(anilist_episode(&unknown.media, i32::MAX as u32 + 1).is_err());
|
||||
}
|
||||
|
||||
+321
-18
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
use ratatui::crossterm::event::KeyModifiers;
|
||||
use std::{
|
||||
fs,
|
||||
@@ -26,6 +27,7 @@ impl Directory {
|
||||
player: self.0.join("missing-player").into_os_string(),
|
||||
default_config: false,
|
||||
miruro: true,
|
||||
anilist: false,
|
||||
query: query.map(str::to_owned),
|
||||
};
|
||||
Browser::new(
|
||||
@@ -576,7 +578,7 @@ fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
fn episode_checks_preserve_check_order_and_cached_screen_history() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
let directory = Directory::new();
|
||||
@@ -595,7 +597,7 @@ fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
.map(|video| video.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
assert!(browser.hints().contains("play checked"));
|
||||
assert!(browser.hints().contains("clear checks"));
|
||||
let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
|
||||
@@ -610,6 +612,11 @@ fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(text.contains("2 checked"));
|
||||
browser.select(Action::VideoLast);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(ids(&browser), ["miruro:first:3"]);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
|
||||
if let Content::Series {
|
||||
data: Some(data), ..
|
||||
@@ -618,13 +625,13 @@ fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
data.reverse();
|
||||
}
|
||||
browser.screen_mut().rebuild_display();
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
browser.history(false);
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
browser.history(true);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
browser.screen_mut().selection.select(None);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
browser.select(Action::VideoLast);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
@@ -676,20 +683,14 @@ fn catalog_and_saved_show_rows_cannot_be_checked() {
|
||||
enter_series(&mut browser);
|
||||
assert_eq!(browser.screen().len(), 2);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from(["miruro:first:3".into()])
|
||||
);
|
||||
assert_eq!(browser.screen().checked, ["miruro:first:3"]);
|
||||
browser.edit_search();
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(browser.input.as_deref(), Some(" "));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
browser.input = None;
|
||||
browser.history(false);
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from(["miruro:first:3".into()])
|
||||
);
|
||||
assert_eq!(browser.screen().checked, ["miruro:first:3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -715,10 +716,7 @@ fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() {
|
||||
browser.rebuild_collections();
|
||||
assert_eq!(browser.screen().checked.len(), 2);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from([second.id.clone()])
|
||||
);
|
||||
assert_eq!(browser.screen().checked, [second.id.clone()]);
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, second.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
@@ -757,7 +755,7 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
|
||||
);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()]));
|
||||
assert_eq!(browser.screen().checked, [first.id.clone()]);
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, first.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.options.player = "/usr/bin/true".into();
|
||||
@@ -768,3 +766,308 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anilist_search_results_open_without_waiting_for_another_catalog_request() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("config.toml");
|
||||
fs::write(&path, "").unwrap();
|
||||
let mut browser = Browser::new(
|
||||
Options::parse_from(["tubular", "--anilist", "Cowboy Bebop"]),
|
||||
path.clone(),
|
||||
config::Config::default(),
|
||||
State::open(&path).unwrap(),
|
||||
);
|
||||
let media = miruro_browser::catalog(vec![serde_json::json!({
|
||||
"id":"1", "catalog_provider":"anilist",
|
||||
"title":{"romaji":"Cowboy Bebop"}, "status":"FINISHED",
|
||||
"episode_count":26, "catalog_count":26, "description":"Search synopsis"
|
||||
})])
|
||||
.unwrap();
|
||||
browser.accept(browser.generation, Ok(Response::Catalog(media)));
|
||||
browser.pending = None;
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().map(|video| video.id),
|
||||
Some("anilist:1:1".into()),
|
||||
"opening a complete search result must expose episodes immediately"
|
||||
);
|
||||
assert!(browser.pending.is_none());
|
||||
assert!(browser.screen().overview.contains("Search synopsis"));
|
||||
browser.select(Action::VideoLast);
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().unwrap().id,
|
||||
"anilist:1:26"
|
||||
);
|
||||
|
||||
browser.refresh();
|
||||
assert!(
|
||||
matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "1")
|
||||
);
|
||||
let refreshed = miruro_browser::anilist_series(serde_json::json!({
|
||||
"id":"1", "catalog_provider":"anilist", "title":{"romaji":"Cowboy Bebop"},
|
||||
"status":"FINISHED", "episode_count":27
|
||||
}))
|
||||
.unwrap();
|
||||
browser.accept(browser.generation, Ok(Response::Series(refreshed)));
|
||||
assert_eq!(browser.screen().len(), 27);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("config.toml");
|
||||
let options = Options::parse_from(["tubular", "--anilist"]);
|
||||
let mut browser = Browser::new(
|
||||
options,
|
||||
path.clone(),
|
||||
config::Config::default(),
|
||||
State::open(&path).unwrap(),
|
||||
);
|
||||
let series = miruro_browser::anilist_series(serde_json::json!({"id":"1","catalog_provider":"anilist","title":{"romaji":"Unknown total"},"status":"RELEASING","episode_count":null,"catalog_count":null})).unwrap();
|
||||
browser.push(Screen::new(Content::Series {
|
||||
media: series.media,
|
||||
data: Some(series.episodes),
|
||||
}));
|
||||
browser.edit_search();
|
||||
assert!(browser.episode_input);
|
||||
browser.input = Some("7".into());
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let selected = browser.screen().selected_video().unwrap();
|
||||
assert_eq!(selected.id, "anilist:1:7");
|
||||
browser.save(CollectionKind::Saved);
|
||||
let mut legacy = selected.clone();
|
||||
legacy.id = "miruro:1:7".into();
|
||||
legacy.channel = Some("Miruro".into());
|
||||
browser.state.save(CollectionKind::Saved, &legacy).unwrap();
|
||||
browser.collection(CollectionKind::Saved);
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
assert_eq!(browser.screen().selected_video().unwrap().id, selected.id);
|
||||
browser.history(false);
|
||||
for invalid in ["0", "01", "-1", "2147483648"] {
|
||||
browser.select_episode(invalid);
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
}
|
||||
browser.select_episode("8");
|
||||
assert!(browser.screen().error.is_none());
|
||||
browser.screen_mut().toggle_checked();
|
||||
browser.select_episode("7");
|
||||
browser.screen_mut().toggle_checked();
|
||||
let queue = browser.screen().playback_videos();
|
||||
assert_eq!(
|
||||
queue
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["anilist:1:8", "anilist:1:7"]
|
||||
);
|
||||
assert_eq!(
|
||||
crate::youtube::video_url(&queue[0].id).unwrap(),
|
||||
"https://zokoanime.video/stream/ani/1/8/sub"
|
||||
);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
|
||||
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
|
||||
assert!(!browser.episode_input);
|
||||
}
|
||||
|
||||
fn anilist_browser(directory: &Directory, count: u32) -> Browser {
|
||||
let mut browser = directory.browser(None);
|
||||
browser.provider = Provider::AniList;
|
||||
browser.options.anilist = true;
|
||||
browser.options.miruro = false;
|
||||
browser.state.select_catalog(Provider::AniList);
|
||||
let series = miruro_browser::anilist_series(serde_json::json!({
|
||||
"id":"1", "catalog_provider":"anilist", "title":{"romaji":"Dragon Ball"},
|
||||
"status":"FINISHED", "episode_count":count, "catalog_count":count,
|
||||
"episode_duration_minutes":25, "format":"TV", "season_year":1986
|
||||
}))
|
||||
.unwrap();
|
||||
browser.push(Screen::new(Content::Series {
|
||||
media: series.media,
|
||||
data: Some(series.episodes),
|
||||
}));
|
||||
browser
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_candidates_use_rendered_offset_and_three_line_viewport_not_series_size() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
let directory = Directory::new();
|
||||
let mut browser = anilist_browser(&directory, 153);
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let initial = browser.source_visible.clone();
|
||||
assert!(initial.len() < 153);
|
||||
assert_eq!(initial.first().unwrap(), "anilist:1:1");
|
||||
let [_, _, content, _, _] = ui::browser_layout(terminal.backend().buffer().area, 1);
|
||||
assert_eq!(
|
||||
initial.len(),
|
||||
usize::from(content.height.saturating_sub(2)) / 3
|
||||
);
|
||||
browser.select(Action::VideoLast);
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let offset = browser.screen().selection.offset();
|
||||
assert!(offset > 0);
|
||||
assert_eq!(
|
||||
browser.source_visible.first().unwrap(),
|
||||
&format!("anilist:1:{}", offset + 1)
|
||||
);
|
||||
assert!(browser.source_visible.contains(&"anilist:1:153".into()));
|
||||
assert!(
|
||||
!browser
|
||||
.source_visible
|
||||
.iter()
|
||||
.any(|key| initial.contains(key))
|
||||
);
|
||||
let selection = browser.screen().selected_key().unwrap().to_owned();
|
||||
let now = Instant::now(); // Inject one fixed clock for every scheduler transition in this test.
|
||||
browser.update_source_checks(now);
|
||||
assert_eq!(
|
||||
browser.source_checks.status(&selection),
|
||||
Some(source_check::Status::Failed)
|
||||
);
|
||||
assert_eq!(browser.screen().selected_key(), Some(selection.as_str()));
|
||||
browser.update_source_checks(now);
|
||||
assert_eq!(browser.screen().selected_key(), Some(selection.as_str()));
|
||||
assert_eq!(browser.screen().len(), 153);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_eligibility_pauses_under_popups_loading_and_catalogs_then_resumes_from_frame() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = anilist_browser(&directory, 10);
|
||||
let candidates = |browser: &mut Browser| {
|
||||
browser.update_visible_sources(8);
|
||||
browser.source_visible.clone()
|
||||
};
|
||||
assert_eq!(candidates(&mut browser), ["anilist:1:1", "anilist:1:2"]);
|
||||
browser.help = Some(0);
|
||||
assert!(candidates(&mut browser).is_empty());
|
||||
browser.help = None;
|
||||
browser.preview = Some(PreviewMode::Info);
|
||||
assert!(candidates(&mut browser).is_empty());
|
||||
browser.preview = None;
|
||||
browser.input = Some(String::new());
|
||||
assert!(candidates(&mut browser).is_empty());
|
||||
browser.input = None;
|
||||
browser.removal = Some((CollectionKind::Saved, vec![]));
|
||||
assert!(candidates(&mut browser).is_empty());
|
||||
browser.removal = None;
|
||||
browser.modal = Some(LibraryModal::Categories {
|
||||
names: vec![],
|
||||
checked: BTreeSet::new(),
|
||||
picker: Picker::default(),
|
||||
});
|
||||
assert!(candidates(&mut browser).is_empty());
|
||||
browser.modal = None;
|
||||
browser.request(
|
||||
Request::Episodes {
|
||||
media_id: "1".into(),
|
||||
slug: String::new(),
|
||||
},
|
||||
Intent::Series("1".into()),
|
||||
);
|
||||
assert!(candidates(&mut browser).is_empty());
|
||||
browser.invalidate();
|
||||
assert!(browser.source_visible.is_empty());
|
||||
assert_eq!(candidates(&mut browser).len(), 2);
|
||||
browser.history(false);
|
||||
assert!(candidates(&mut browser).is_empty());
|
||||
browser.history(true);
|
||||
assert_eq!(candidates(&mut browser).len(), 2);
|
||||
browser.provider = Provider::Miruro;
|
||||
assert!(candidates(&mut browser).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_checks_are_session_only_and_collection_rows_keep_selection_checks_and_watch_state() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
let directory = Directory::new();
|
||||
let mut browser = anilist_browser(&directory, 10);
|
||||
let video = browser.screen().selected_video().unwrap();
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.set_watch_status(WatchStatus::Watched);
|
||||
browser.collection(CollectionKind::Saved);
|
||||
browser.screen_mut().toggle_checked();
|
||||
let checks = browser.screen().checked.clone();
|
||||
let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap();
|
||||
let watch = fs::read(directory.0.join("watch-state.toml")).unwrap();
|
||||
let config = fs::read(&browser.config_path).unwrap();
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
browser.source_visible.as_slice(),
|
||||
std::slice::from_ref(&video.id)
|
||||
);
|
||||
assert_eq!(browser.rows()[0].height(), 3);
|
||||
let now = Instant::now(); // Fixed injected clock; no elapsed-time dependency.
|
||||
browser.update_source_checks(now);
|
||||
assert_eq!(
|
||||
browser.source_checks.status(&video.id),
|
||||
Some(source_check::Status::Failed)
|
||||
);
|
||||
assert_eq!(browser.screen().selected_key(), Some(video.id.as_str()));
|
||||
assert_eq!(browser.screen().checked, checks);
|
||||
assert!(browser.state.is_watched(&video.id));
|
||||
assert_eq!(
|
||||
fs::read(directory.0.join("saved-videos.toml")).unwrap(),
|
||||
saved
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(directory.0.join("watch-state.toml")).unwrap(),
|
||||
watch
|
||||
);
|
||||
assert_eq!(fs::read(&browser.config_path).unwrap(), config);
|
||||
browser.refresh();
|
||||
assert_eq!(browser.source_checks.status(&video.id), None);
|
||||
assert_eq!(browser.screen().selected_key(), Some(video.id.as_str()));
|
||||
browser.provider = Provider::Miruro;
|
||||
assert_eq!(browser.rows()[0].height(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episode_three_lines_align_after_shared_marker_and_leave_metadata_second() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
let directory = Directory::new();
|
||||
let mut browser = anilist_browser(&directory, 2);
|
||||
let video = browser.screen().selected_video().unwrap();
|
||||
browser.set_watch_status(WatchStatus::Watched);
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let find = |needle: &str| {
|
||||
(0..buffer.area.height)
|
||||
.find_map(|y| {
|
||||
let line = (0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>();
|
||||
line.find(needle)
|
||||
.map(|byte| (line[..byte].chars().count(), y))
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let title = find("Episode 1");
|
||||
let metadata = find("25 min avg");
|
||||
let source = find(browser.source_checks.label(&video.id));
|
||||
assert_eq!(metadata, (title.0, title.1 + 1));
|
||||
assert_eq!(source, (title.0, title.1 + 2));
|
||||
assert_eq!(browser.screen().selected_key(), Some(video.id.as_str()));
|
||||
for metadata in ["25 min avg", "TV", "1986 series"] {
|
||||
assert!(browser.screen().rows[0].details.contains(metadata));
|
||||
}
|
||||
browser.screen_mut().toggle_checked();
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
assert!(browser.state.is_watched(&video.id));
|
||||
assert_eq!(browser.screen().checked, [video.id]);
|
||||
}
|
||||
|
||||
+127
-2
@@ -59,7 +59,7 @@ fn record(id: &str, event: &str, eligible: bool, position: f64) -> String {
|
||||
|
||||
fn miruro_video(episode: u32) -> Video {
|
||||
Video {
|
||||
id: Identity::new("opaque_Show-1".into(), episode)
|
||||
id: Identity::miruro("opaque_Show-1".into(), episode)
|
||||
.unwrap()
|
||||
.key(),
|
||||
title: format!("Example show · Episode {episode}"),
|
||||
@@ -580,6 +580,70 @@ fn watched_write_failure_keeps_memory_unchanged_and_retries_consumed_reports() {
|
||||
assert!(!state.poll_progress().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn historical_progress_sessions_leave_descriptors_available_for_subprocesses() {
|
||||
const CHILD: &str = "TUBULAR_PROGRESS_DESCRIPTOR_TEST";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"video_state::tests::historical_progress_sessions_leave_descriptors_available_for_subprocesses",
|
||||
"--test-threads=1",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Isolate the process-wide limit from the rest of the test suite.
|
||||
let mut limit = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
// SAFETY: limit is valid writable storage; only this child process is affected.
|
||||
unsafe {
|
||||
assert_eq!(libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit), 0);
|
||||
limit.rlim_cur = limit.rlim_cur.min(64);
|
||||
assert_eq!(libc::setrlimit(libc::RLIMIT_NOFILE, &limit), 0);
|
||||
}
|
||||
|
||||
let directory = TestDirectory::new();
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
let video = video();
|
||||
let reports = state.progress_files(&vec![video.clone(); 96]).unwrap();
|
||||
for report in &reports {
|
||||
register_identity(report, &video.id).unwrap();
|
||||
append(report, record(&video.id, "progress", false, 0.0).as_bytes());
|
||||
}
|
||||
let last = reports.last().unwrap();
|
||||
let completion = record(&video.id, "closed", true, 95.0);
|
||||
append(last, &completion.as_bytes()[..completion.len() - 1]);
|
||||
drop(state);
|
||||
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
for _ in 0..12 {
|
||||
state.poll_progress().unwrap();
|
||||
}
|
||||
assert!(!state.is_watched(&video.id));
|
||||
append(last, b"\n");
|
||||
for _ in 0..6 {
|
||||
state.poll_progress().unwrap();
|
||||
}
|
||||
assert!(state.is_watched(&video.id));
|
||||
let output = std::process::Command::new("/usr/bin/true")
|
||||
.output()
|
||||
.expect("progress monitoring must leave descriptors available for subprocess pipes");
|
||||
assert!(output.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() {
|
||||
let directory = TestDirectory::new();
|
||||
@@ -1313,7 +1377,7 @@ fn miruro_sessions_recover_the_maximum_identity_marker() {
|
||||
let directory = TestDirectory::new();
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
let mut video = miruro_video(i32::MAX as u32);
|
||||
video.id = Identity::new("a".repeat(512), i32::MAX as u32)
|
||||
video.id = Identity::miruro("a".repeat(512), i32::MAX as u32)
|
||||
.unwrap()
|
||||
.key();
|
||||
let report = state.progress_file(&video).unwrap();
|
||||
@@ -1449,3 +1513,64 @@ fn progress_batch_prevalidates_snapshots_and_session_capacity() {
|
||||
assert!(!state.sessions.exists());
|
||||
assert_eq!(state.streams.len(), MAX_STREAMS - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anilist_library_and_progress_are_isolated_from_opaque_miruro_ids() {
|
||||
let directory = TestDirectory::new();
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
let mut legacy_show = show();
|
||||
legacy_show.id = "1".into();
|
||||
legacy_show.metadata["id"] = serde_json::json!("1");
|
||||
state
|
||||
.save_show(&legacy_show, &["Legacy".into()].into())
|
||||
.unwrap();
|
||||
let legacy_bytes = fs::read(directory.0.join("miruro-library.toml")).unwrap();
|
||||
state.select_catalog(Provider::AniList);
|
||||
assert!(state.shows().is_empty());
|
||||
assert!(state.save_show(&legacy_show, &BTreeSet::new()).is_err());
|
||||
let mut ani_show = legacy_show.clone();
|
||||
ani_show.metadata["catalog_provider"] = serde_json::json!("anilist");
|
||||
state.save_show(&ani_show, &["New".into()].into()).unwrap();
|
||||
let mut ani = miruro_video(1);
|
||||
ani.id = Identity::anilist("1".into(), 1).unwrap().key();
|
||||
ani.channel = Some("AniList".into());
|
||||
let mut legacy = miruro_video(1);
|
||||
legacy.id = Identity::miruro("1".into(), 1).unwrap().key();
|
||||
for item in [&ani, &legacy, &video()] {
|
||||
state.save(CollectionKind::Saved, item).unwrap();
|
||||
}
|
||||
let report = state.progress_file(&ani).unwrap();
|
||||
register_identity(&report, &ani.id).unwrap();
|
||||
append(
|
||||
&report,
|
||||
miruro_record(&legacy, "ended", true, 100.0, Some(100.0)).as_bytes(),
|
||||
);
|
||||
append(
|
||||
&report,
|
||||
miruro_record(&ani, "progress", false, 20.0, Some(100.0)).as_bytes(),
|
||||
);
|
||||
drop(state);
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
assert!(state.poll_progress().unwrap());
|
||||
assert!(state.contains(CollectionKind::Watching, &ani.id));
|
||||
assert!(!state.is_watched(&ani.id));
|
||||
assert!(!state.is_watched(&legacy.id));
|
||||
append(
|
||||
&report,
|
||||
miruro_record(&ani, "ended", true, 95.0, Some(100.0)).as_bytes(),
|
||||
);
|
||||
assert!(state.poll_progress().unwrap());
|
||||
assert!(state.is_watched(&ani.id));
|
||||
assert!(!state.is_watched(&legacy.id));
|
||||
assert!(state.contains(CollectionKind::Saved, &legacy.id));
|
||||
assert_eq!(state.shows()[0].tags, ["Legacy".into()].into());
|
||||
state.select_catalog(Provider::AniList);
|
||||
assert_eq!(state.shows()[0].tags, ["New".into()].into());
|
||||
assert_eq!(
|
||||
fs::read(directory.0.join("miruro-library.toml")).unwrap(),
|
||||
legacy_bytes
|
||||
);
|
||||
let mut forged = ani.clone();
|
||||
forged.channel = Some("Miruro".into());
|
||||
assert!(state.progress_file(&forged).is_err());
|
||||
}
|
||||
|
||||
@@ -542,6 +542,10 @@ fn video_urls_preserve_provider_identity_and_reject_invalid_keys() {
|
||||
video_url("miruro:opaque_ID-7:3").unwrap(),
|
||||
"https://barelystarted.miruro.tv/watch/opaque_ID-7/episode?ep=3"
|
||||
);
|
||||
assert_eq!(
|
||||
video_url("anilist:1:3").unwrap(),
|
||||
"https://zokoanime.video/stream/ani/1/3/sub"
|
||||
);
|
||||
for id in [
|
||||
"",
|
||||
"--help",
|
||||
@@ -549,6 +553,10 @@ fn video_urls_preserve_provider_identity_and_reject_invalid_keys() {
|
||||
"miruro:id:0",
|
||||
"miruro:id:03",
|
||||
"miruro:../id:3",
|
||||
"anilist:01:3",
|
||||
"anilist:1:03",
|
||||
"anilist:1:0",
|
||||
"anilist:1:2147483648",
|
||||
] {
|
||||
assert_eq!(
|
||||
video_url(id).unwrap_err().kind(),
|
||||
@@ -779,6 +787,120 @@ fn search_sanitizes_display_metadata_and_rejects_invalid_envelopes() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_playlist_listing_uses_flat_ids_sanitizes_and_deduplicates() {
|
||||
// yt-dlp's /playlists tab returns URL rows, not nested playlist contents.
|
||||
let playlists = channel_playlists(
|
||||
r#"{"_type":"playlist","entries":[
|
||||
{"_type":"url","ie_key":"YoutubeTab","id":"PLJ5MFiMfElFA","title":"Rust\u001b"},
|
||||
null,42,{},{"id":"PLJ5MFiMfElFA","title":"Duplicate"},
|
||||
{"id":"PLabcdefghij&list=other","title":"Unsafe"},
|
||||
{"id":"rQ_J9WH6CGk","title":"Video"},
|
||||
{"id":"PLabcdefghij","title":null},
|
||||
{"id":"PLabcdefghij","title":"\u001b\n"},
|
||||
{"id":"OLAK5uy_abcdefghij","title":"Release"}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
playlists
|
||||
.iter()
|
||||
.map(|playlist| (playlist.id.as_str(), playlist.title.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[("PLJ5MFiMfElFA", "Rust"), ("OLAK5uy_abcdefghij", "Release")]
|
||||
);
|
||||
assert!(channel_playlists(r#"{"entries":[]}"#).unwrap().is_empty());
|
||||
for output in ["not JSON", "{}", r#"{"entries":null}"#, r#"{"entries":{}}"#] {
|
||||
assert!(channel_playlists(output).is_err());
|
||||
assert!(playlist_results(output).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlist_ids_are_safe_and_support_music_and_legacy_families() {
|
||||
for id in [
|
||||
"PLJ5MFiMfElFA",
|
||||
"OLAK5uy_abcdefghij-_",
|
||||
"UUabcdefghij",
|
||||
"RDabcdefghij",
|
||||
"RDMM",
|
||||
"WL",
|
||||
"LL",
|
||||
"LM",
|
||||
] {
|
||||
assert!(valid_playlist_id(id), "{id}");
|
||||
assert!(matches!(
|
||||
Job::playlist(id, 0),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
for id in [
|
||||
"",
|
||||
"--bad",
|
||||
"PLshort",
|
||||
"PLabcdefghij&x=1",
|
||||
"PLabcdefghij#x",
|
||||
"PLabcdefghij/",
|
||||
] {
|
||||
assert!(!valid_playlist_id(id), "{id}");
|
||||
assert!(matches!(
|
||||
Job::playlist(id, 1),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
assert!(Job::channel_playlists("https://example.com/@channel").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlists_preserve_order_repeats_and_creator_identity_with_raw_pagination() {
|
||||
let mut entries = vec![serde_json::Value::Null; PAGE_SIZE];
|
||||
entries[0] = serde_json::json!({
|
||||
"id":"l-GaaivVJCE","title":"First\u{1b}","timestamp":100
|
||||
});
|
||||
entries[1] = serde_json::json!({
|
||||
"id":"SLV9AJxnb1U","title":"Second","timestamp":300,
|
||||
"channel":"Another creator","channel_id":"UC4QobU6STFB0P71PMvOGN5A"
|
||||
});
|
||||
entries[2] = serde_json::json!({"id":"bad","title":"Invalid"});
|
||||
entries[3] = serde_json::json!({"id":"jNQXAC9IVRw","title":null});
|
||||
entries[4] = serde_json::json!({
|
||||
"id":"jNQXAC9IVRw","title":"Members only","availability":"subscriber_only"
|
||||
});
|
||||
entries[5] = entries[0].clone();
|
||||
entries[6] = serde_json::json!({
|
||||
"id":"rQ_J9WH6CGk","title":"Owner upload","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA"
|
||||
});
|
||||
let output = serde_json::json!({
|
||||
"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos",
|
||||
"channel":"Rust\u{1b}","entries":entries
|
||||
});
|
||||
let page = playlist_results(&output.to_string()).unwrap();
|
||||
assert!(page.has_more);
|
||||
assert!(page.feed_progress.is_empty());
|
||||
assert_eq!(
|
||||
page.videos
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["l-GaaivVJCE", "SLV9AJxnb1U", "l-GaaivVJCE", "rQ_J9WH6CGk"]
|
||||
);
|
||||
assert_eq!(page.videos[0].title, "First");
|
||||
assert_eq!(page.videos[0].channel, None);
|
||||
assert_eq!(page.videos[0].channel_id, None);
|
||||
assert_eq!(page.videos[0].uploader_id, None);
|
||||
assert_eq!(page.videos[3].channel.as_deref(), Some("Rust"));
|
||||
assert_eq!(page.videos[3].uploader_id.as_deref(), Some("@RustVideos"));
|
||||
assert_eq!(page.videos[1].channel.as_deref(), Some("Another creator"));
|
||||
assert_eq!(
|
||||
page.videos[1].channel_id.as_deref(),
|
||||
Some("UC4QobU6STFB0P71PMvOGN5A")
|
||||
);
|
||||
assert_eq!(page.videos[1].uploader_id, None);
|
||||
assert!(!playlist_results(r#"{"entries":[]}"#).unwrap().has_more);
|
||||
assert!(playlist_results(r#"{"channel_id":"invalid","entries":[]}"#).is_err());
|
||||
assert!(playlist_results(r#"{"uploader_id":"@bad\u001b","entries":[]}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() {
|
||||
let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]}
|
||||
|
||||
Reference in New Issue
Block a user