mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 07:21:33 -07:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
96ecb4a136
|
|||
|
10ad19f934
|
|||
|
fbfdea7c64
|
|||
|
a2533118eb
|
|||
|
722222c651
|
|||
|
558e00cb44
|
|||
|
4b85ad3eb4
|
|||
|
336e9fb8a5
|
|||
|
dec7851a17
|
|||
|
4eaaf5769f
|
|||
|
0dfba67ae8
|
|||
|
18e0200934
|
|||
|
d7188a2155
|
|||
|
1c50ff74cb
|
|||
|
c2781a3f80
|
|||
|
ff25e5cafa
|
|||
|
7254db66ad
|
|||
|
3c19228b35
|
|||
|
2e34f01adf
|
|||
|
f5e98dfa9d
|
|||
|
e64ff1a0ee
|
@@ -86,6 +86,10 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
<td><b>Jellyfin</b></td>
|
||||
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Anime Browser</b></td>
|
||||
<td>Search anime sources you supply as <a href="https://github.com/aniyomiorg/aniyomi">Aniyomi</a> extension APKs and play an episode in mpv with the overlay attached (<code>subminer anime</code>); SubMiner ships no repositories and bundles no sources</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Jimaku</b></td>
|
||||
<td>Search and download Japanese subtitles</td>
|
||||
@@ -256,19 +260,28 @@ Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more
|
||||
|
||||
SubMiner builds on the work of these open-source projects:
|
||||
|
||||
| Project | Role |
|
||||
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
||||
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
| Project | Role |
|
||||
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
||||
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||
| [Aniyomi](https://github.com/aniyomiorg/aniyomi) | Anime extension API and data model the anime browser targets |
|
||||
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server) | Runs Aniyomi extension APKs off Android; the bridge the anime browser drives |
|
||||
| [Mangatan](https://github.com/1Selxo/Mangatan) | Reference client for the bridge protocol the anime browser speaks |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License v3.0](LICENSE)
|
||||
|
||||
The anime browser drives [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server),
|
||||
downloaded from its upstream releases at runtime rather than
|
||||
bundled or redistributed here; its bundles carry their own dependencies, including a JRE and
|
||||
GPL-3.0 NewPipe Extractor. SubMiner includes none of them and talks to the bridge
|
||||
over its own HTTP protocol. It ships no extensions or repositories by default.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Resolving "season N" against AniList no longer lands one season short for franchises whose broadcast seasons are listed as several entries. AniList records the back half of a split cour ("… Season 2 Part 2", "… Cour 2", "第2クール") as its own sequel, and the resolver counted each as a season — so Mushoku Tensei season 3 resolved to the season 2 entry and Re:ZERO season 4 to season 3, which then drove the character dictionary and AniList progress updates to the wrong series. A sequel carrying a part or cour marker that matches the season it continues is now followed without advancing the season count, in both the relation walk and the air-order fallback. Titles are compared ignoring punctuation, and the marker is looked for in every title and synonym rather than just the display title. A wrong match cached before this fix is remembered in `character-dictionaries/anilist-resolution-cache.json` and needs removing (or overriding from the character dictionary picker) for the affected series.
|
||||
- A special or OVA sitting in a franchise's sequel chain is walked through without counting as a season. Dr. STONE links STONE WARS to New World through a one-episode special, which made New World resolve as season 4 and every later season shift with it.
|
||||
- An AniList rate limit or network failure while walking sequel relations is reported so the caller can retry, instead of falling through to the air-date fallback. That fallback is for a franchise with missing relation edges; running it after a failed lookup turned a transient 429 into a confidently wrong season.
|
||||
- The shared title parser recognizes spelled-out episode labels (`Episode 4`, `第4話`) and a season named at the end of the title (`… Season 3`, `… 3rd Season`, `… S3`), which now fill the Season field instead of being searched for as part of the series name. The TsukiHime modal gained a Season field to match Jimaku, and seasons after the first are included in its search query so a later season's releases are actually found.
|
||||
@@ -0,0 +1,26 @@
|
||||
type: added
|
||||
area: anime
|
||||
|
||||
- Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual.
|
||||
- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
|
||||
- Anime extensions are read from `<userData>/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
|
||||
- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per extension and source, and updated extension schemas replace stale saved field definitions without losing values. Older unscoped preferences are discarded once because their package ownership cannot be proven safely.
|
||||
- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason. Repository requests time out instead of hanging, and APK updates are staged before replacing the installed copy.
|
||||
- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
|
||||
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
|
||||
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
|
||||
- The Extensions tab opens with an Installed section listing every extension on disk with Remove — including ones added by hand or whose repository has since been removed — and Update where a configured repository still carries it.
|
||||
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
||||
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
||||
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments, so streams ffmpeg would otherwise probe as "a PNG" and abandon now play in mpv.
|
||||
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
|
||||
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
|
||||
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
|
||||
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
|
||||
- The Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
|
||||
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
|
||||
- The Extensions tab's available list has a language chip row: pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter. Rows name the language ("Japanese" instead of `ja`) and the Available heading counts what the filter leaves.
|
||||
- A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits.
|
||||
- An episode launched from the browser carries its series, season and episode number through the app instead of a single joined string: stats group by series (every stream previously landed in one entry named `m3u8`), rewatching reuses the existing entry, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing, AniList updates use those fields directly, and the mpv title reads `Series S03E04 - Episode Name`.
|
||||
- Opening the browser shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher and Dock, so you can switch between it and mpv; the Dock icon is released again when the window closes during playback. Launching a video starts a regular SubMiner session, and in `subminer anime` standalone mode closing the window during playback no longer quits the app and kills the stream.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed the overlay never loading (stuck on the "Overlay loading" OSD spinner) when the Yomitan content-script reload raced overlay window creation at startup, most visible when playing from the anime browser on Linux/Wayland: a hidden window stops painting after that reload, so the ready-to-show signal that gates showing the overlay never fired. Content-ready now falls back to did-finish-load after a short grace period.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: subsync
|
||||
|
||||
- Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. The track is downloaded to a temporary file first — reusing mpv's own request headers, so authenticated and referer-gated hosts stay reachable — instead of being rejected with "Subtitle file not found: https://…". This applies to both the sync target and the alass reference, so a Jimaku or TsukiHime download can be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
|
||||
- Alass now works on tracks that arrive as WebVTT, which is what Aniyomi extension streams serve. Alass picks its parser from the file extension and has no WebVTT support, so it treated a `.vtt` reference as a video file and failed with "no audio stream in file". Both the target and the reference are rewritten as SRT for alass, keeping the cue text as-is, and the originals are left untouched.
|
||||
- Leaving `subsync.alass_path`, `ffsubsync_path`, or `ffmpeg_path` empty now actually auto-discovers the binary, as the config help has always claimed. Previously it fell back to a hard-coded `/usr/bin/<tool>`, which does not exist on macOS and broke subsync for every default-config install there. Discovery searches `PATH` plus the usual install prefixes (a GUI launch inherits a minimal `PATH`) and accepts `alass-cli` as well as `alass`. An explicitly configured path is still used verbatim and never silently substituted.
|
||||
- Subsync failures are written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
|
||||
+13
-1
@@ -607,10 +607,22 @@
|
||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||
}, // Is kiku setting.
|
||||
"lapisKiku": {
|
||||
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards: word-and-sentence (IsWordAndSentenceCard), click (IsClickCard), sentence (IsSentenceCard), audio (IsAudioCard), or none to leave the flags untouched. Only applies when isKiku.enabled or isLapis.enabled; the other card-type flags are cleared so a note never claims two kinds. Cards mined with the dedicated sentence/audio actions keep their own flag. Values: word-and-sentence | click | sentence | audio | none
|
||||
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||
} // Lapis kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
// ==========================================
|
||||
// Anime Browser
|
||||
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
|
||||
// Hot-reload: anime changes apply the next time the anime browser opens.
|
||||
// ==========================================
|
||||
"anime": {
|
||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||
"preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
// ==========================================
|
||||
// Jimaku
|
||||
// Jimaku API configuration and defaults.
|
||||
|
||||
@@ -327,6 +327,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'Anki', link: '/anki-integration' },
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Anime Browser', link: '/anime-browser' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'TsukiHime', link: '/tsukihime-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
# Anime Browser
|
||||
|
||||
Search anime sources, pick an episode, and play it in mpv with SubMiner's overlay
|
||||
and mining tools attached — the same way a local file or a Jellyfin stream works.
|
||||
|
||||
Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
|
||||
**Browse Anime** in the tray menu. The window stays open while you watch, so you
|
||||
can queue the next episode without reopening it.
|
||||
|
||||
While the window is open, SubMiner shows a tray icon and — on macOS — appears
|
||||
in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
|
||||
flip between the browser and mpv. SubMiner normally hides itself from the Dock
|
||||
because the subtitle overlay needs that to float above fullscreen video; it
|
||||
hides again when the window closes during playback.
|
||||
|
||||
Launching an episode starts a full SubMiner playback session, the same as
|
||||
playing a local file: the overlay and mining tools attach, and the tray icon
|
||||
stays available. In standalone `subminer anime` mode, closing the window while
|
||||
a video is playing leaves playback running — reopen the browser from the tray
|
||||
(**Browse Anime**). The app only exits with the window when nothing is playing.
|
||||
|
||||
## How it works
|
||||
|
||||
SubMiner does not implement any anime source itself. It runs **Aniyomi extension
|
||||
APKs** through a bundled JVM sidecar ([M-Extension-Server][mes]), asks the
|
||||
selected extension to resolve an episode, and hands the resulting URL to mpv.
|
||||
|
||||
```
|
||||
extension APK → bridge (JVM) → { url, headers } → mpv → SubMiner overlay
|
||||
```
|
||||
|
||||
Because the extension resolves the stream, whichever sources you install decide
|
||||
what is available. SubMiner only hosts them.
|
||||
|
||||
## Installing extensions
|
||||
|
||||
**SubMiner ships no extension repositories and bundles no sources.** There is no
|
||||
default repository, no suggested list, and no discovery. Until you add one, the
|
||||
browser has nothing to search — that is deliberate, and it is what keeps SubMiner
|
||||
a neutral host rather than a distributor.
|
||||
|
||||
There are two ways to add extensions.
|
||||
|
||||
### From a repository
|
||||
|
||||
The window has three tabs — **Browse**, **Extensions**, and **Source settings** —
|
||||
and each one fills the window, so a long extension list is not squeezed in above
|
||||
the search results.
|
||||
|
||||
Open the **Extensions** tab, paste a repository index URL, and choose
|
||||
**Add repository**. The URL must be `https` and point at a `.json` index file —
|
||||
`index.min.json` is the common Aniyomi name, but repositories are free to publish
|
||||
under another one (for example `video.min.json`). Anything else is rejected
|
||||
immediately rather than failing later. Everything before the file name is treated
|
||||
as the repository root, so `.apk` and icon URLs are resolved relative to it.
|
||||
|
||||
Extensions your repositories offer but you do not have appear under
|
||||
**Available**, each with **Install**. Repositories are stored in config under
|
||||
`anime.repos`, so you can also manage them there and keep them in a dotfile.
|
||||
|
||||
A repository index lists every language it knows about, which is far more than
|
||||
any one person reads, so the **Available** list has a language chip row above
|
||||
it. Pick one or more languages to narrow it, or **All** to clear the filter;
|
||||
picking a language replaces **All** rather than sitting beside it. Rows name the
|
||||
language in full ("Japanese" rather than `ja`), extensions whose sources span
|
||||
languages are grouped under **Multi-language**, and the Available heading counts
|
||||
how many of the offered extensions the filter leaves.
|
||||
|
||||
### Managing what is installed
|
||||
|
||||
The Extensions tab opens with an **Installed** section listing everything in the
|
||||
extensions directory, with the sources each one provides and a **Remove**
|
||||
button. It is built from the directory rather than from a repository, so an
|
||||
extension you dropped in by hand — or one whose repository you have since
|
||||
removed — is still listed and still removable.
|
||||
|
||||
**Update** appears next to an extension a configured repository still carries;
|
||||
it downloads the current version over the existing APK.
|
||||
|
||||
### From a file
|
||||
|
||||
Drop Aniyomi `.apk` files into the extensions directory, shown at the top of the
|
||||
Extensions tab. It defaults to `<userData>/anime-extensions` — on macOS,
|
||||
`~/Library/Application Support/SubMiner/anime-extensions` — and can be moved with
|
||||
`anime.extensionsDir`.
|
||||
|
||||
A single APK may provide several sources; each appears separately in the
|
||||
**Source** picker. Extensions that fail to load are listed in the Installed
|
||||
section with the reason, so a broken APK is visible rather than silently
|
||||
missing.
|
||||
|
||||
An extension that fails to load is skipped rather than blocking the others, so
|
||||
one bad APK will not hide the rest.
|
||||
|
||||
## Searching every source at once
|
||||
|
||||
With more than one source installed, the **Source** picker gains an
|
||||
**All sources** entry. Searching with it selected runs the query against every
|
||||
installed source at once, and each source's results appear the moment that
|
||||
source answers — a fast source is on screen while a slow one is still
|
||||
resolving. The status bar counts sources as they finish
|
||||
(`Searching… 3/5 sources · 42 results`).
|
||||
|
||||
Each cover is labelled with the source it came from, and opening one always
|
||||
queries that source, whatever the picker says afterwards.
|
||||
|
||||
A source that errors is named in the status bar and the rest still show their
|
||||
results; one extension that needs a login cannot blank the grid. If every
|
||||
source fails, the first error is shown in full.
|
||||
|
||||
Typing a new search while one is still running simply starts over: results
|
||||
from the superseded search are discarded, even if its sources answer late.
|
||||
|
||||
When a source reports another page, **Load more** appears below the covers.
|
||||
It appends the next page without duplicating entries that already arrived in
|
||||
the live result stream. A failed next-page request remains available to retry.
|
||||
|
||||
Source settings belong to a single extension, so the **Source settings** tab
|
||||
asks you to pick one while **All sources** is selected.
|
||||
|
||||
## Settings
|
||||
|
||||
| Key | Purpose |
|
||||
| ------------------------ | -------------------------------------------------------------------- |
|
||||
| `anime.repos` | Repository index URLs. Empty by default. |
|
||||
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
|
||||
|
||||
## Source settings
|
||||
|
||||
Most extensions need configuration before they return anything — a server
|
||||
address and credentials, a preferred quality, a language filter. Open the
|
||||
**Source settings** tab to edit them. Changes save as you make them and
|
||||
persist across restarts in `<userData>/anime-source-preferences.json`.
|
||||
|
||||
Each save is handed back to the extension, so it can react: the Jellyfin source
|
||||
logs in when the address and password land, then fills in its media-library
|
||||
picker. Password-like fields are masked. Because that file can hold
|
||||
credentials, it is written with owner-only permissions. Values are scoped to
|
||||
the exact extension package and source, so two extensions that reuse the same
|
||||
internal source ID cannot read each other's settings. Preferences saved by an
|
||||
older build without package ownership are discarded; re-enter those source
|
||||
settings once after upgrading.
|
||||
|
||||
## The bridge
|
||||
|
||||
The first launch downloads a platform bundle (~130 MB) containing the server and
|
||||
a matching Java runtime, so no system JDK is required. SubMiner pins one
|
||||
upstream release tag and the SHA-256 of each asset it has verified; the download
|
||||
is checked against that hash before anything runs, then unpacked into
|
||||
`<userData>/anime-bridge` and reused after that. Progress appears in the banner
|
||||
at the top of the window.
|
||||
|
||||
The bridge stays running while the window is open. Resolved video URLs point at
|
||||
its own loopback proxy so the extension's cookies and headers apply, which means
|
||||
those URLs stop working once it exits — the window keeps it alive for the whole
|
||||
session.
|
||||
|
||||
If the bridge dies anyway (killed by hand, crashed, or stopped mid-operation),
|
||||
the exit is detected and named in the status bar, and the next request starts a
|
||||
new one. Playback already in flight still ends when its stream URL dies, but the
|
||||
browser recovers without an app restart.
|
||||
|
||||
Two known limits:
|
||||
|
||||
- There is no Android WebView, so extensions that need one (typically for
|
||||
Cloudflare challenges) will fail with an error from the source.
|
||||
- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64),
|
||||
but only the ones we have hashed ourselves will run: currently macOS arm64 and
|
||||
Linux x64. The rest stop with "No pinned checksum for …" until a maintainer
|
||||
verifies them. Other platforms are unsupported outright.
|
||||
|
||||
## Playback
|
||||
|
||||
Selecting an episode resolves the best available stream, applies the source's
|
||||
required headers as mpv `http-header-fields`, and loads it. The headers are
|
||||
readable back off mpv, so Anki card audio and screenshots fetch correctly too.
|
||||
|
||||
HLS streams are routed through a small local proxy before mpv sees them. Some
|
||||
hosts disguise their video segments by prepending a fake image header (a real
|
||||
1x1 PNG) so scrapers back off; Aniyomi's own player strips this, but ffmpeg
|
||||
probes the segment as a picture and playback dies with "no audio or video data
|
||||
played". The proxy scans each segment for the first genuine MPEG-TS packet run
|
||||
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
|
||||
subtitles, encryption keys) pass through untouched, and direct-file streams
|
||||
skip the proxy entirely.
|
||||
|
||||
"Playing" in the status bar means playing: after handing mpv the stream,
|
||||
SubMiner waits until mpv actually configures a video output before reporting
|
||||
success. If mpv gives up instead — a dead host, an undecodable stream — the
|
||||
browser shows mpv's error rather than pretending playback started (a failed
|
||||
load leaves no mpv window, because the player idles windowless).
|
||||
|
||||
### Japanese audio, and switching tracks
|
||||
|
||||
Sources often return a dub and the original audio as two separate entries — or
|
||||
as two audio tracks of one stream — and the dub is frequently listed first.
|
||||
SubMiner always aims at the Japanese audio:
|
||||
|
||||
- Entries labelled as a dub are skipped as long as another entry exists. This
|
||||
outranks `anime.preferredQuality`: a 1080p dub is the wrong file, not a better
|
||||
one. If every entry is a dub, it still plays.
|
||||
- mpv's `alang` is set to `ja,jpn,jp,japanese` before the file loads, so a
|
||||
stream carrying several audio tracks starts on the Japanese one. With no
|
||||
Japanese track, mpv falls back to the first one as usual.
|
||||
- Any audio or subtitle tracks the extension supplies separately are added to
|
||||
mpv with `audio-add` / `sub-add`, tagged with their language, and the
|
||||
Japanese one is selected.
|
||||
|
||||
The primary subtitle slot is reserved for Japanese — it is what the overlay
|
||||
mines. A source that only carries, say, English subtitles does not get them
|
||||
promoted to primary; instead the track is added with a normalized language tag
|
||||
(`English` → `en`), and the regular [dual-subtitle settings](configuration.md)
|
||||
apply: with `secondarySub.autoLoadSecondarySub` enabled and the language listed
|
||||
in `secondarySub.secondarySubLanguages`, it is picked up as the secondary
|
||||
subtitle, exactly as it would be for a local file.
|
||||
|
||||
Every track is added, including the ones that are not selected, so all of them
|
||||
appear in mpv's track menu and can be switched by hand while watching
|
||||
(`#` cycles audio, `j` cycles subtitles by default).
|
||||
|
||||
The extension hands over subtitle tracks as URLs, but SubMiner downloads each
|
||||
one to a temporary directory and gives mpv the local file. mpv is happy either
|
||||
way; [Subsync](/troubleshooting#subtitle-sync-subsync) is not, because alass
|
||||
needs a file on disk to use as the timing reference. A track that fails to
|
||||
download falls back to its URL so the episode still plays, the format is
|
||||
detected from the file's own content rather than its URL, and the directory is
|
||||
removed when the next episode starts or the app exits.
|
||||
|
||||
### Series, season, and episode
|
||||
|
||||
The episode's identity travels with it instead of being guessed back out of the
|
||||
stream URL, which carries nothing but a proxy path and a file extension. The
|
||||
title and episode label from the source's own listing are split into series,
|
||||
season, and episode number once, at launch, and everything downstream reads
|
||||
those fields:
|
||||
|
||||
- mpv's title reads `Series S03E04 - Episode Name`.
|
||||
- Stats group by series, and rewatching an episode reuses its entry instead of
|
||||
creating a new one.
|
||||
- The [Jimaku](/jimaku-integration) and [TsukiHime](/tsukihime-integration)
|
||||
modals open with Title, Season, and Episode already filled in, so a subtitle
|
||||
search is one keypress rather than a retype.
|
||||
- [AniList](/anilist-integration) progress updates use those fields directly.
|
||||
|
||||
[mes]: https://github.com/1Selxo/M-Extension-Server
|
||||
@@ -154,6 +154,7 @@ The configuration file includes several main sections:
|
||||
|
||||
**External Integrations**
|
||||
|
||||
- [**Anime Browser**](#anime-browser) - Extension repositories and stream preferences for the anime browser
|
||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
|
||||
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||
@@ -1154,6 +1155,30 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
|
||||
|
||||
## External Integrations
|
||||
|
||||
### Anime Browser
|
||||
|
||||
Sources for the [anime browser](/anime-browser). SubMiner ships no extension repositories and bundles no sources, so these are empty until you add one:
|
||||
|
||||
```json
|
||||
{
|
||||
"anime": {
|
||||
"extensionsDir": "",
|
||||
"repos": [],
|
||||
"preferredQuality": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------------ | ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
|
||||
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
|
||||
|
||||
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
|
||||
|
||||
Per-source settings (server addresses, credentials, per-extension quality or language options) are not part of `config.jsonc`. They belong to the extension, are edited in the browser's **Source settings** tab, and persist in `<userData>/anime-source-preferences.json` with owner-only permissions.
|
||||
|
||||
### Jimaku
|
||||
|
||||
Configure Jimaku API access and defaults:
|
||||
@@ -1214,11 +1239,15 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. |
|
||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty falls back to `/usr/bin/ffsubsync`. `ffsubsync` must be installed separately. |
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||
| `alass_path` | string path | Path to `alass` executable. Empty auto-discovers `alass` or `alass-cli`. `alass` must be installed separately. |
|
||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty auto-discovers `ffsubsync`. `ffsubsync` must be installed separately. |
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` auto-discovers `ffmpeg`. |
|
||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||
|
||||
Auto-discovery searches `PATH`, then the usual install prefixes (`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/usr/bin`, `/bin`) — a GUI launch inherits a minimal `PATH` that often omits the first two. Set the option explicitly if your binary lives elsewhere.
|
||||
|
||||
Subtitle tracks that mpv loaded from a URL (Aniyomi extension streams, Jellyfin) are downloaded to a temporary file first, reusing mpv's own request headers, so they can be used as either the sync target or the alass reference.
|
||||
|
||||
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
|
||||
|
||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||
|
||||
@@ -23,12 +23,12 @@ If no files match the current episode filter, a "Show all files" button lets you
|
||||
|
||||
### Modal Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `Enter` (in text field) | Search |
|
||||
| `Enter` (in list) | Select entry / download file |
|
||||
| `Arrow Up` / `Arrow Down` | Navigate entries or files |
|
||||
| `Escape` | Close modal |
|
||||
| Key | Action |
|
||||
| ------------------------- | ---------------------------- |
|
||||
| `Enter` (in text field) | Search |
|
||||
| `Enter` (in list) | Select entry / download file |
|
||||
| `Arrow Up` / `Arrow Down` | Navigate entries or files |
|
||||
| `Escape` | Close modal |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -41,26 +41,26 @@ Add a `jimaku` section to your `config.jsonc`:
|
||||
"apiKeyCommand": "cat ~/.jimaku_key",
|
||||
"apiBaseUrl": "https://jimaku.cc",
|
||||
"languagePreference": "ja",
|
||||
"maxEntryResults": 10
|
||||
}
|
||||
"maxEntryResults": 10,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
|
||||
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
|
||||
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
|
||||
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
|
||||
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
|
||||
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
|
||||
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
|
||||
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
|
||||
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
|
||||
|
||||
The keyboard shortcut is configured separately under `shortcuts`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"shortcuts": {
|
||||
"openJimaku": "Ctrl+Shift+J"
|
||||
}
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,6 +79,8 @@ SubMiner extracts media info from the current video path to pre-fill the search
|
||||
|
||||
- **Season + episode patterns:** `S01E03`, `1x03`
|
||||
- **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -`
|
||||
- **Spelled-out episode labels:** `Episode 4`, `第4話`
|
||||
- **Season named at the end of the title:** `… Season 3`, `… 3rd Season`, `… S3`, `… 第3期` - the season goes in the Season field instead of being searched for as part of the series name
|
||||
- **Season folders:** a parent directory named `Season 2` or `S2` fills in the season when the filename lacks one
|
||||
- **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction
|
||||
- **Year tags:** `(2024)` - stripped
|
||||
@@ -87,6 +89,8 @@ SubMiner extracts media info from the current video path to pre-fill the search
|
||||
|
||||
If the parser produces a high-confidence result (title + episode both detected), the search runs automatically when the modal opens. Otherwise, you can adjust the fields manually before searching.
|
||||
|
||||
Episodes launched from the [anime browser](/anime-browser) skip parsing entirely: Title, Season, and Episode come from the source's own listing, which is why the search runs immediately even though the stream URL says nothing about the episode.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Jimaku API key not set"**
|
||||
|
||||
@@ -607,10 +607,22 @@
|
||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||
}, // Is kiku setting.
|
||||
"lapisKiku": {
|
||||
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards: word-and-sentence (IsWordAndSentenceCard), click (IsClickCard), sentence (IsSentenceCard), audio (IsAudioCard), or none to leave the flags untouched. Only applies when isKiku.enabled or isLapis.enabled; the other card-type flags are cleared so a note never claims two kinds. Cards mined with the dedicated sentence/audio actions keep their own flag. Values: word-and-sentence | click | sentence | audio | none
|
||||
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||
} // Lapis kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
// ==========================================
|
||||
// Anime Browser
|
||||
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
|
||||
// Hot-reload: anime changes apply the next time the anime browser opens.
|
||||
// ==========================================
|
||||
"anime": {
|
||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||
"preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
// ==========================================
|
||||
// Jimaku
|
||||
// Jimaku API configuration and defaults.
|
||||
|
||||
@@ -207,21 +207,26 @@ Resume playback and wait for the next subtitle to appear, then try mining again.
|
||||
|
||||
Both **alass** and **ffsubsync** are optional external dependencies. Subtitle syncing requires at least one of them to be installed.
|
||||
|
||||
**"Configured alass executable not found"**
|
||||
Subsync writes to the application log under the `subsync` scope, so the full command failure — exit code, stderr, resolved file paths — is recorded there as well as on the OSD.
|
||||
|
||||
**"Could not find alass" / "Configured alass executable not found"**
|
||||
|
||||
Install alass or configure the path:
|
||||
|
||||
- **Homebrew**: `brew install alass`
|
||||
- **Arch Linux (AUR)**: `paru -S alass`
|
||||
- **Cargo**: `cargo install alass-cli`
|
||||
- Set the path: `subsync.alass_path` in your config.
|
||||
|
||||
**"Configured ffsubsync executable not found"**
|
||||
Leaving the option empty searches `PATH` plus the usual install prefixes, and accepts either `alass` or `alass-cli`. Set the option explicitly when the binary lives somewhere else. The second message means the configured path itself does not exist — SubMiner never silently substitutes a different binary for one you named.
|
||||
|
||||
**"Could not find ffsubsync" / "Configured ffsubsync executable not found"**
|
||||
|
||||
Install ffsubsync or configure the path:
|
||||
|
||||
- **Arch Linux (AUR)**: `paru -S python-ffsubsync`
|
||||
- **pip**: `pip install ffsubsync`
|
||||
- Must be on `PATH` or configured via `subsync.ffsubsync_path` in your config.
|
||||
- Must be discoverable, or configured via `subsync.ffsubsync_path` in your config.
|
||||
|
||||
**"alass synchronization failed" / "ffsubsync synchronization failed"**
|
||||
|
||||
@@ -232,6 +237,38 @@ If subtitle sync fails (the error message is prefixed with the engine name):
|
||||
- Try running the sync tool manually to see detailed error output.
|
||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||
|
||||
**Syncing subtitles on a stream (Aniyomi extensions, Jellyfin)**
|
||||
|
||||
Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first, reusing mpv's own request headers, so they work as either the sync target or the alass reference. Downloading a Japanese track from Jimaku or TsukiHime while a stream is playing also works — it becomes the primary track and therefore the sync target.
|
||||
|
||||
Internal subtitle tracks of a stream still go through `ffmpeg`, which has to reach the origin itself. If that fails, prefer an external track or a Jimaku download as the reference.
|
||||
|
||||
Streams usually serve WebVTT, which alass cannot parse — it picks its parser from the file extension and treats a `.vtt` file as a video, failing with "no audio stream in file". SubMiner rewrites both the target and the reference as SRT for alass, so this is handled automatically; the retimed track mpv loads is that SRT.
|
||||
|
||||
## Anime Browser
|
||||
|
||||
See the [anime browser guide](/anime-browser) for how sources and the bridge work.
|
||||
|
||||
**Nothing to search**
|
||||
|
||||
SubMiner ships no extension repositories and bundles no sources. Until you add a repository index URL in the **Extensions** tab (or drop Aniyomi `.apk` files into the extensions directory) the browser has nothing to query.
|
||||
|
||||
**"No pinned checksum for …"**
|
||||
|
||||
The bridge bundle is verified against a SHA-256 that a maintainer has checked by hand. Bundles exist for macOS (arm64, x64), Linux (x64), and Windows (x64), but only macOS arm64 and Linux x64 are pinned so far; the rest stop with this message rather than running an unverified download. Other platforms are unsupported outright.
|
||||
|
||||
**A source returns nothing, or asks for a login**
|
||||
|
||||
Most extensions need configuration first - a server address, credentials, a preferred quality. Open the **Source settings** tab and fill them in; each save is handed straight back to the extension. Sources that need an Android WebView (typically for Cloudflare challenges) cannot work here, because the bridge has none.
|
||||
|
||||
**"Playback failed" with an mpv error**
|
||||
|
||||
"Playing" is only reported once mpv actually configures a video output, so this is a real failure rather than a silent one: a dead host, an expired stream URL, or an undecodable stream. Resolve the episode again; if it keeps failing, try another source or quality entry.
|
||||
|
||||
**The browser starts failing every request**
|
||||
|
||||
A bridge that dies (killed, crashed, stopped mid-operation) is named in the status bar and restarted on the next request. Anything already playing ends with it, since stream URLs point at its loopback proxy.
|
||||
|
||||
## TsukiHime
|
||||
|
||||
**"xz binary not found"**
|
||||
|
||||
@@ -16,11 +16,11 @@ Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is th
|
||||
|
||||
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
|
||||
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately. Episodes launched from the [anime browser](/anime-browser) fill all three fields from the source's own listing instead of from a filename.
|
||||
|
||||
From there:
|
||||
|
||||
1. **Search** - SubMiner queries TsukiHime with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
|
||||
1. **Search** - SubMiner queries TsukiHime with `<title> <season> <episode>`. Season 1 is left out on purpose: releases of a first season almost never carry `S01` in their name, so including it would match nothing. Later seasons are included, which is what makes their releases findable at all. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
|
||||
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
|
||||
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ subminer https://youtu.be/... # Play a YouTube URL
|
||||
subminer stats # Open the immersion stats dashboard
|
||||
subminer doctor # Check dependencies, config, and the mpv socket
|
||||
subminer settings # Open the SubMiner settings window
|
||||
subminer anime # Open the anime browser window
|
||||
subminer app --setup # Re-open first-run setup
|
||||
subminer -u # Check for updates
|
||||
```
|
||||
@@ -131,6 +132,7 @@ SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
||||
SubMiner.AppImage --anime # Open the anime browser window
|
||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode
|
||||
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Domain Ownership
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-07-15
|
||||
Last verified: 2026-08-02
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
@@ -27,6 +27,10 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
|
||||
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
|
||||
`src/main/runtime/anime-browser-runtime.ts`, `src/main/runtime/anime-browser-ipc-handlers.ts`,
|
||||
`src/main/runtime/anime-bridge-installer.ts`, `src/main/runtime/stream-playback-metadata.ts`
|
||||
- Window trackers: `src/window-trackers/`
|
||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||
in `src/core/services/stats-server/`
|
||||
@@ -44,6 +48,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Settings UI contracts: `src/types/settings.ts`
|
||||
- Session-binding contracts: `src/types/session-bindings.ts`
|
||||
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
|
||||
- Anime browser contracts: `src/types/anime-browser.ts`, bridge wire types in `src/anime-bridge/types.ts`
|
||||
- Compatibility-only barrel: `src/types.ts`
|
||||
|
||||
## Ownership Heuristics
|
||||
|
||||
@@ -7,21 +7,21 @@ Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: finding internal docs or checking verification status
|
||||
|
||||
| Area | Path | Status | Last verified | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
|
||||
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
| Area | Path | Status | Last verified | Notes |
|
||||
| ------------------------ | ----------------------------------------------- | ------ | ------------- | ------------------------------------------ |
|
||||
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-08-02 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
|
||||
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
|
||||
## Update Rules
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ type AppCommandDeps = {
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
launchAnimeBrowserDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
};
|
||||
|
||||
const defaultAppCommandDeps: AppCommandDeps = {
|
||||
@@ -23,6 +27,8 @@ const defaultAppCommandDeps: AppCommandDeps = {
|
||||
launchSyncUiDetached: (appPath, logLevel) =>
|
||||
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
|
||||
launchAppBackgroundDetached,
|
||||
launchAnimeBrowserDetached: (appPath, logLevel) =>
|
||||
launchAppCommandDetached(appPath, ['--anime'], logLevel, 'anime'),
|
||||
};
|
||||
|
||||
export function runAppPassthroughCommand(
|
||||
@@ -37,6 +43,11 @@ export function runAppPassthroughCommand(
|
||||
deps.runAppCommandWithInherit(appPath, ['--settings']);
|
||||
return true;
|
||||
}
|
||||
if (args.animeBrowser) {
|
||||
// Detached: the browser window is long-lived and owns the bridge process.
|
||||
deps.launchAnimeBrowserDetached(appPath, args.logLevel);
|
||||
return true;
|
||||
}
|
||||
if (args.syncUi) {
|
||||
deps.launchSyncUiDetached(appPath, args.logLevel);
|
||||
return true;
|
||||
|
||||
@@ -207,6 +207,7 @@ test('app command starts default macOS background app detached from launcher', (
|
||||
calls.push('attached');
|
||||
},
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAnimeBrowserDetached: () => {},
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -227,6 +228,7 @@ test('app command starts default Linux background app detached from launcher', (
|
||||
calls.push('attached');
|
||||
},
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAnimeBrowserDetached: () => {},
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -248,6 +250,7 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
launchSyncUiDetached: () => detached.push('sync-ui'),
|
||||
launchAnimeBrowserDetached: () => {},
|
||||
launchAppBackgroundDetached: () => {
|
||||
detached.push('detached');
|
||||
},
|
||||
@@ -266,6 +269,7 @@ test('sync UI command launches the app detached from the terminal', () => {
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
runAppCommandWithInherit: () => calls.push('piped'),
|
||||
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
|
||||
launchAnimeBrowserDetached: () => calls.push('anime'),
|
||||
launchAppBackgroundDetached: () => calls.push('detached'),
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ function createContext(): LauncherCommandContext {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -120,6 +120,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
logLevel: 'warn',
|
||||
},
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -171,6 +172,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
||||
settingsInvocation: {
|
||||
logLevel: undefined,
|
||||
},
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -215,6 +217,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
action: undefined,
|
||||
},
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -257,6 +260,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
|
||||
@@ -168,6 +168,7 @@ export function createDefaultArgs(
|
||||
version: false,
|
||||
update: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -348,6 +349,12 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
);
|
||||
}
|
||||
|
||||
if (invocations.animeInvocation) {
|
||||
if (invocations.animeInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.animeInvocation.logLevel);
|
||||
}
|
||||
parsed.animeBrowser = true;
|
||||
}
|
||||
if (invocations.settingsInvocation) {
|
||||
if (invocations.settingsInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface CliInvocations {
|
||||
jellyfinInvocation: JellyfinInvocation | null;
|
||||
configInvocation: CommandActionInvocation | null;
|
||||
settingsInvocation: CommandActionInvocation | null;
|
||||
animeInvocation: CommandActionInvocation | null;
|
||||
mpvInvocation: CommandActionInvocation | null;
|
||||
appInvocation: { appArgs: string[] } | null;
|
||||
dictionaryTriggered: boolean;
|
||||
@@ -102,6 +103,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'doctor',
|
||||
'config',
|
||||
'settings',
|
||||
'anime',
|
||||
'mpv',
|
||||
'logs',
|
||||
'dictionary',
|
||||
@@ -155,6 +157,7 @@ export function parseCliPrograms(
|
||||
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||
let configInvocation: CommandActionInvocation | null = null;
|
||||
let settingsInvocation: CommandActionInvocation | null = null;
|
||||
let animeInvocation: CommandActionInvocation | null = null;
|
||||
let mpvInvocation: CommandActionInvocation | null = null;
|
||||
let appInvocation: { appArgs: string[] } | null = null;
|
||||
let dictionaryTriggered = false;
|
||||
@@ -415,6 +418,16 @@ export function parseCliPrograms(
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('anime')
|
||||
.description('Open the anime browser window')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((options: Record<string, unknown>) => {
|
||||
animeInvocation = {
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('mpv')
|
||||
.description('MPV helpers')
|
||||
@@ -469,6 +482,7 @@ export function parseCliPrograms(
|
||||
jellyfinInvocation,
|
||||
configInvocation,
|
||||
settingsInvocation,
|
||||
animeInvocation,
|
||||
mpvInvocation,
|
||||
appInvocation,
|
||||
dictionaryTriggered,
|
||||
|
||||
@@ -57,6 +57,7 @@ function createArgs(): Args {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -629,6 +629,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface Args {
|
||||
version: boolean;
|
||||
update?: boolean;
|
||||
settings: boolean;
|
||||
animeBrowser: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
|
||||
+2
-1
@@ -21,10 +21,11 @@
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:animeui && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
|
||||
"build:animeui": "esbuild src/animeui/animeui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/animeui/animeui.js --sourcemap && esbuild src/preload-animeui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-animeui.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build-release",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||
|
||||
@@ -3,14 +3,33 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
function buildScript(name: string): string {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
|
||||
) as { scripts: Record<string, string> };
|
||||
const command = packageJson.scripts['build:syncui'] ?? '';
|
||||
return packageJson.scripts[name] ?? '';
|
||||
}
|
||||
|
||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const command = buildScript('build:syncui');
|
||||
|
||||
assert.match(command, /src\/preload-syncui\.ts/);
|
||||
assert.match(command, /--bundle/);
|
||||
assert.match(command, /--external:electron/);
|
||||
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
|
||||
});
|
||||
|
||||
test('build:animeui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const command = buildScript('build:animeui');
|
||||
|
||||
// The preload imports IPC_CHANNELS, so it must be bundled rather than
|
||||
// emitted by plain tsc with a relative runtime require.
|
||||
assert.match(command, /src\/preload-animeui\.ts/);
|
||||
assert.match(command, /--bundle/);
|
||||
assert.match(command, /--external:electron/);
|
||||
assert.match(command, /--outfile=dist\/preload-animeui\.js/);
|
||||
});
|
||||
|
||||
test('build:animeui runs as part of the top-level build', () => {
|
||||
assert.match(buildScript('build'), /bun run build:animeui/);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
||||
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
||||
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
|
||||
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
|
||||
const animeUiSourceDir = path.join(repoRoot, 'src', 'animeui');
|
||||
const animeUiOutputDir = path.join(repoRoot, 'dist', 'animeui');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
@@ -25,9 +27,11 @@ function copyFile(sourcePath, outputPath) {
|
||||
fs.copyFileSync(sourcePath, outputPath);
|
||||
}
|
||||
|
||||
function copyAssets(sourceDir, outputDir, label) {
|
||||
function copyAssets(sourceDir, outputDir, label, stylesheets = ['style.css']) {
|
||||
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
|
||||
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
|
||||
for (const stylesheet of stylesheets) {
|
||||
copyFile(path.join(sourceDir, stylesheet), path.join(outputDir, stylesheet));
|
||||
}
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
@@ -47,6 +51,14 @@ function copySyncUiAssets() {
|
||||
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
||||
}
|
||||
|
||||
function copyAnimeUiAssets() {
|
||||
copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui', [
|
||||
'style.css',
|
||||
'detail.css',
|
||||
'panels.css',
|
||||
]);
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
@@ -90,6 +102,7 @@ function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
copyAnimeUiAssets();
|
||||
buildMacosHelper();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
|
||||
@@ -18,3 +18,19 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
|
||||
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
|
||||
);
|
||||
});
|
||||
|
||||
test('anime UI stylesheet files exist and are all staged', () => {
|
||||
const html = readFileSync('src/animeui/index.html', 'utf8');
|
||||
const stylesheets = [...html.matchAll(/<link rel="stylesheet" href="\.\/(.+?\.css)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
assert.deepEqual(stylesheets, ['style.css', 'detail.css', 'panels.css']);
|
||||
for (const stylesheet of stylesheets) {
|
||||
assert.equal(existsSync(`src/animeui/${stylesheet}`), true, `${stylesheet} must exist`);
|
||||
}
|
||||
assert.match(
|
||||
source,
|
||||
/copyAssets\(animeUiSourceDir, animeUiOutputDir, 'animeui', \[\s*'style\.css',\s*'detail\.css',\s*'panels\.css',?\s*\]\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AnimeBridgeClient, BridgeExtensionError } from './bridge-client';
|
||||
import { BRIDGE_CONTEXT_KEY } from './types';
|
||||
|
||||
const EXTENSION_ID = 'a'.repeat(64);
|
||||
const APK_BASE64 = 'QVBLLUJZVEVT';
|
||||
const source = {
|
||||
fingerprint: 'sha-1',
|
||||
loadApkBase64: async () => APK_BASE64,
|
||||
sourceId: 'source-1',
|
||||
};
|
||||
|
||||
interface Recorded {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stubFetch(responder: (call: Recorded, index: number) => Response): {
|
||||
fetchImpl: typeof fetch;
|
||||
calls: Recorded[];
|
||||
} {
|
||||
const calls: Recorded[] = [];
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const call: Recorded = {
|
||||
url: String(input),
|
||||
body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {},
|
||||
};
|
||||
calls.push(call);
|
||||
return responder(call, calls.length - 1);
|
||||
}) as typeof fetch;
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, extensionId?: string): Response {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (extensionId) headers.set('x-mangatan-extension-id', extensionId);
|
||||
return new Response(JSON.stringify(body), { status: 200, headers });
|
||||
}
|
||||
|
||||
test('isReady requires every capability the client depends on', async () => {
|
||||
const ready = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: stubFetch(() =>
|
||||
jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
|
||||
).fetchImpl,
|
||||
});
|
||||
assert.equal(await ready.isReady(), true);
|
||||
|
||||
const partial = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: stubFetch(() => jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true }))
|
||||
.fetchImpl,
|
||||
});
|
||||
assert.equal(await partial.isReady(), false);
|
||||
});
|
||||
|
||||
test('isReady caps the probe at the deadline the caller passes', async () => {
|
||||
// A bridge that accepts the socket and then stalls: only the abort ends it.
|
||||
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
await new Promise((resolve) => init?.signal?.addEventListener('abort', resolve));
|
||||
throw new Error('aborted');
|
||||
}) as typeof fetch;
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const started = Date.now();
|
||||
assert.equal(await client.isReady(50), false);
|
||||
// Well under the 5s default, so the per-call deadline is what applied.
|
||||
assert.ok(Date.now() - started < 1000, 'probe outlived the caller deadline');
|
||||
});
|
||||
|
||||
test('isReady reports false instead of throwing when the bridge is down', async () => {
|
||||
const client = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}) as typeof fetch,
|
||||
});
|
||||
assert.equal(await client.isReady(), false);
|
||||
});
|
||||
|
||||
test('getVideoList posts the APK and episode url with a bridge context preference', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ videoUrl: 'http://x/video/t' }]));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9/', fetchImpl });
|
||||
|
||||
const videos = await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
|
||||
assert.equal(calls[0]?.url, 'http://127.0.0.1:9/dalvik');
|
||||
assert.equal(calls[0]?.body.method, 'getVideoList');
|
||||
assert.deepEqual(calls[0]?.body.episodeData, { url: 'https://origin.example/ep/1' });
|
||||
assert.equal(calls[0]?.body.data, APK_BASE64);
|
||||
assert.deepEqual(calls[0]?.body.preferences, [{ key: BRIDGE_CONTEXT_KEY, sourceId: 'source-1' }]);
|
||||
assert.equal(videos.length, 1);
|
||||
});
|
||||
|
||||
test('a cached extension id replaces the APK upload on later calls', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
await client.getVideoList(source, 'https://origin.example/ep/2');
|
||||
|
||||
assert.equal(calls[0]?.body.data, APK_BASE64);
|
||||
assert.equal(calls[0]?.body.extensionId, undefined);
|
||||
assert.equal(calls[1]?.body.data, undefined);
|
||||
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
||||
});
|
||||
|
||||
test('an upgraded APK re-uploads instead of reusing the previous extension id', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
// Same source id, new build in the same file: the id cache must miss.
|
||||
const upgraded = { ...source, fingerprint: 'sha-2', loadApkBase64: async () => 'TkVXLUFQSw==' };
|
||||
await client.getVideoList(upgraded, 'https://origin.example/ep/2');
|
||||
|
||||
assert.equal(calls[1]?.body.extensionId, undefined);
|
||||
assert.equal(calls[1]?.body.data, 'TkVXLUFQSw==');
|
||||
});
|
||||
|
||||
test('a 409 re-uploads the APK once and succeeds', async () => {
|
||||
const { fetchImpl, calls } = stubFetch((call, index) => {
|
||||
if (index === 0) return jsonResponse([], EXTENSION_ID);
|
||||
// Cache evicted: reject the id-only call, accept the re-upload.
|
||||
if (call.body.extensionId !== undefined) return new Response('', { status: 409 });
|
||||
return jsonResponse([{ videoUrl: 'http://x/video/t' }], EXTENSION_ID);
|
||||
});
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
const videos = await client.getVideoList(source, 'https://origin.example/ep/2');
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
||||
assert.equal(calls[2]?.body.data, APK_BASE64);
|
||||
assert.equal(videos.length, 1);
|
||||
});
|
||||
|
||||
test('an error body on a 200 response raises BridgeExtensionError with the code', async () => {
|
||||
const { fetchImpl } = stubFetch(() => jsonResponse({ error: 'Cloudflare challenge', code: 403 }));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => client.getVideoList(source, 'https://origin.example/ep/1'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof BridgeExtensionError);
|
||||
assert.equal(error.code, 403);
|
||||
assert.match(error.message, /Cloudflare challenge/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('searchAnime sends a 1-based page and returns the page payload', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() =>
|
||||
jsonResponse({ animes: [{ title: 'Example' }], hasNextPage: true }),
|
||||
);
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const page = await client.searchAnime(source, 'example');
|
||||
|
||||
assert.equal(calls[0]?.body.method, 'getSearchAnime');
|
||||
assert.equal(calls[0]?.body.page, 1);
|
||||
assert.equal(calls[0]?.body.search, 'example');
|
||||
assert.deepEqual(calls[0]?.body.filterList, []);
|
||||
assert.equal(page.hasNextPage, true);
|
||||
assert.equal(page.animes?.length, 1);
|
||||
});
|
||||
|
||||
test('getEpisodeList wraps the anime url in animeData', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ name: 'Episode 1', url: '/ep/1' }]));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const episodes = await client.getEpisodeList(source, 'https://origin.example/anime/1');
|
||||
|
||||
assert.equal(calls[0]?.body.method, 'getEpisodeList');
|
||||
assert.deepEqual(calls[0]?.body.animeData, { url: 'https://origin.example/anime/1' });
|
||||
assert.equal(episodes[0]?.name, 'Episode 1');
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { BRIDGE_CONTEXT_KEY } from './types';
|
||||
import type {
|
||||
BridgeAnime,
|
||||
BridgeAnimePage,
|
||||
BridgeCapabilities,
|
||||
BridgeEpisode,
|
||||
BridgePreference,
|
||||
BridgeSourceDescriptor,
|
||||
BridgeVideo,
|
||||
} from './types';
|
||||
|
||||
const EXTENSION_ID_HEADER = 'x-mangatan-extension-id';
|
||||
const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface BridgeSource {
|
||||
/**
|
||||
* Identity of the APK's contents. Keys the extension-id cache, so an upgraded
|
||||
* APK is re-uploaded instead of reusing the previous build's id.
|
||||
*/
|
||||
fingerprint: string;
|
||||
/**
|
||||
* Reads and base64-encodes the APK. Called only when the bridge actually
|
||||
* needs the bytes, so multi-megabyte payloads are not held on the heap.
|
||||
*/
|
||||
loadApkBase64: () => Promise<string>;
|
||||
/** Selects one source inside a multi-source (SourceFactory) APK. */
|
||||
sourceId?: string;
|
||||
preferences?: BridgePreference[];
|
||||
}
|
||||
|
||||
export interface BridgeClientOptions {
|
||||
/** Loopback base URL of the running bridge, e.g. `http://127.0.0.1:53112`. */
|
||||
baseUrl: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* Per-request deadline. Node's `fetch` has none, so a sidecar that accepts
|
||||
* the socket and then stalls would leave every call pending forever.
|
||||
*/
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Extension calls can be slow (a source may scrape several pages). */
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** The readiness probe is a local health check; it should answer at once. */
|
||||
const CAPABILITIES_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The bridge reports extension failures as HTTP 200 with an error body. */
|
||||
export class BridgeExtensionError extends Error {
|
||||
readonly code?: number;
|
||||
constructor(message: string, code?: number) {
|
||||
super(message);
|
||||
this.name = 'BridgeExtensionError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for the M-Extension-Server `/dalvik` RPC endpoint.
|
||||
*
|
||||
* The server caches uploaded APKs and returns a content hash, letting
|
||||
* subsequent calls send that id instead of re-uploading megabytes of base64.
|
||||
* A 409 means the cache was evicted, so the APK is resent once.
|
||||
*/
|
||||
export class AnimeBridgeClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly extensionIds = new Map<string, string>();
|
||||
|
||||
constructor(options: BridgeClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* `timeoutMs` lets a caller with its own deadline (the readiness loop) cap the
|
||||
* probe below the default, so a short readiness budget is actually honored.
|
||||
*/
|
||||
async getCapabilities(timeoutMs = CAPABILITIES_TIMEOUT_MS): Promise<BridgeCapabilities> {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, {
|
||||
signal: AbortSignal.timeout(Math.max(0, Math.min(timeoutMs, this.requestTimeoutMs))),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
|
||||
}
|
||||
return (await response.json()) as BridgeCapabilities;
|
||||
}
|
||||
|
||||
/** True once the bridge is up and reports the features this client needs. */
|
||||
async isReady(timeoutMs?: number): Promise<boolean> {
|
||||
try {
|
||||
const capabilities = await this.getCapabilities(timeoutMs);
|
||||
return (
|
||||
capabilities.mangatanMihonBridge === 1 &&
|
||||
capabilities.sourceFactory === true &&
|
||||
capabilities.preferenceCallbacks === true
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async searchAnime(
|
||||
source: BridgeSource,
|
||||
query: string,
|
||||
page = 1,
|
||||
filterList: unknown[] = [],
|
||||
): Promise<BridgeAnimePage> {
|
||||
return this.call<BridgeAnimePage>(source, 'getSearchAnime', {
|
||||
page,
|
||||
search: query,
|
||||
filterList,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the sources an extension APK provides. A single APK may expose many
|
||||
* (a SourceFactory), so this is how a package becomes selectable entries.
|
||||
*/
|
||||
async listAnimeSources(source: BridgeSource): Promise<BridgeSourceDescriptor[]> {
|
||||
return this.call<BridgeSourceDescriptor[]>(source, 'sourcesAnime', {});
|
||||
}
|
||||
|
||||
/** The extension's own settings schema, with current values. */
|
||||
async getSourcePreferences(source: BridgeSource): Promise<BridgePreference[]> {
|
||||
return this.call<BridgePreference[]>(source, 'preferencesAnime', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a preference change. The whole array is sent back with the edited
|
||||
* entry, and `changedPreferenceKey` tells the extension which one moved so it
|
||||
* can react (the Jellyfin source logs in when the address or password lands).
|
||||
* Returns the extension's refreshed schema.
|
||||
*/
|
||||
async setSourcePreference(
|
||||
source: BridgeSource,
|
||||
changedPreferenceKey: string,
|
||||
): Promise<BridgePreference[]> {
|
||||
return this.call<BridgePreference[]>(source, 'setPreferenceAnime', {}, changedPreferenceKey);
|
||||
}
|
||||
|
||||
/** Full metadata for one anime: description, cover art, genres, status. */
|
||||
async getAnimeDetails(source: BridgeSource, animeUrl: string): Promise<BridgeAnime> {
|
||||
return this.call<BridgeAnime>(source, 'getDetailsAnime', {
|
||||
animeData: { url: animeUrl },
|
||||
});
|
||||
}
|
||||
|
||||
async getPopularAnime(source: BridgeSource, page = 1): Promise<BridgeAnimePage> {
|
||||
return this.call<BridgeAnimePage>(source, 'getPopularAnime', { page });
|
||||
}
|
||||
|
||||
async getEpisodeList(source: BridgeSource, animeUrl: string): Promise<BridgeEpisode[]> {
|
||||
return this.call<BridgeEpisode[]>(source, 'getEpisodeList', {
|
||||
animeData: { url: animeUrl },
|
||||
});
|
||||
}
|
||||
|
||||
async getVideoList(source: BridgeSource, episodeUrl: string): Promise<BridgeVideo[]> {
|
||||
return this.call<BridgeVideo[]>(source, 'getVideoList', {
|
||||
episodeData: { url: episodeUrl },
|
||||
});
|
||||
}
|
||||
|
||||
private buildPreferences(
|
||||
source: BridgeSource,
|
||||
changedPreferenceKey?: string,
|
||||
): BridgePreference[] {
|
||||
const context: BridgePreference = { key: BRIDGE_CONTEXT_KEY };
|
||||
if (source.sourceId !== undefined) context.sourceId = source.sourceId;
|
||||
if (changedPreferenceKey !== undefined) context.changedPreferenceKey = changedPreferenceKey;
|
||||
return [...(source.preferences ?? []), context];
|
||||
}
|
||||
|
||||
private async call<T>(
|
||||
source: BridgeSource,
|
||||
method: string,
|
||||
extras: Record<string, unknown>,
|
||||
changedPreferenceKey?: string,
|
||||
): Promise<T> {
|
||||
// Keyed by APK contents, not by source id: an in-place upgrade keeps the
|
||||
// same source id, and reusing its cached extension id would silently run
|
||||
// the previous build (the bridge has no reason to answer 409).
|
||||
const cacheKey = `${source.fingerprint}:${source.sourceId ?? ''}`;
|
||||
const cachedId = this.extensionIds.get(cacheKey);
|
||||
|
||||
let response = await this.post(method, extras, source, cachedId, changedPreferenceKey);
|
||||
if (response.status === 409 && cachedId !== undefined) {
|
||||
// Server evicted the cached APK; upload it again.
|
||||
this.extensionIds.delete(cacheKey);
|
||||
response = await this.post(method, extras, source, undefined, changedPreferenceKey);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anime bridge ${method} failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const returnedId = response.headers.get(EXTENSION_ID_HEADER)?.trim();
|
||||
if (returnedId && EXTENSION_ID_PATTERN.test(returnedId)) {
|
||||
this.extensionIds.set(cacheKey, returnedId);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as T;
|
||||
assertNoExtensionError(body, method);
|
||||
return body;
|
||||
}
|
||||
|
||||
private async post(
|
||||
method: string,
|
||||
extras: Record<string, unknown>,
|
||||
source: BridgeSource,
|
||||
extensionId: string | undefined,
|
||||
changedPreferenceKey?: string,
|
||||
): Promise<Response> {
|
||||
const payload: Record<string, unknown> = {
|
||||
method,
|
||||
...extras,
|
||||
preferences: this.buildPreferences(source, changedPreferenceKey),
|
||||
...(extensionId === undefined ? { data: await source.loadApkBase64() } : { extensionId }),
|
||||
};
|
||||
|
||||
return this.fetchImpl(`${this.baseUrl}/dalvik`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoExtensionError(body: unknown, method: string): void {
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) return;
|
||||
const error = (body as { error?: unknown }).error;
|
||||
if (typeof error !== 'string') return;
|
||||
const code = (body as { code?: unknown }).code;
|
||||
throw new BridgeExtensionError(
|
||||
`Anime bridge ${method} failed: ${error}`,
|
||||
typeof code === 'number' ? code : undefined,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildAnimeStreamMetadata,
|
||||
buildAnimeStreamStatsPath,
|
||||
buildStreamDisplayTitle,
|
||||
splitEpisodeLabel,
|
||||
splitSeasonFromTitle,
|
||||
} from './episode-metadata';
|
||||
|
||||
test('splitSeasonFromTitle pulls a trailing season marker off the title', () => {
|
||||
assert.deepEqual(splitSeasonFromTitle('Mushoku Tensei: Jobless Reincarnation Season 3'), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
season: 3,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('Spy x Family 2nd Season'), {
|
||||
title: 'Spy x Family',
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('Bocchi the Rock! S2'), {
|
||||
title: 'Bocchi the Rock!',
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('シャングリラ・フロンティア 第2期'), {
|
||||
title: 'シャングリラ・フロンティア',
|
||||
season: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('splitSeasonFromTitle leaves a title without a trailing marker alone', () => {
|
||||
assert.deepEqual(splitSeasonFromTitle('My Teen Romantic Comedy SNAFU Climax!'), {
|
||||
title: 'My Teen Romantic Comedy SNAFU Climax!',
|
||||
season: null,
|
||||
});
|
||||
// "Season" inside the name is not a season marker.
|
||||
assert.deepEqual(splitSeasonFromTitle('A Season of Snow and Ash'), {
|
||||
title: 'A Season of Snow and Ash',
|
||||
season: null,
|
||||
});
|
||||
// Nothing would be left of the title, so the marker is not a marker.
|
||||
assert.deepEqual(splitSeasonFromTitle('Season 2'), { title: 'Season 2', season: null });
|
||||
});
|
||||
|
||||
test('splitEpisodeLabel reads the number and the episode name', () => {
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 4'), { number: 4, title: null });
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 10: Gallantly, Shizuka Hiratsuka Moves Forward.'), {
|
||||
number: 10,
|
||||
title: 'Gallantly, Shizuka Hiratsuka Moves Forward.',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel('Ep. 7 - The Long Road'), {
|
||||
number: 7,
|
||||
title: 'The Long Road',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel('第12話 決戦'), { number: 12, title: '決戦' });
|
||||
assert.deepEqual(splitEpisodeLabel('5. Homecoming'), { number: 5, title: 'Homecoming' });
|
||||
assert.deepEqual(splitEpisodeLabel('13'), { number: 13, title: null });
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 6.5'), { number: 6.5, title: null });
|
||||
});
|
||||
|
||||
test('splitEpisodeLabel keeps a label that carries no number as a name', () => {
|
||||
assert.deepEqual(splitEpisodeLabel('Movie'), { number: null, title: 'Movie' });
|
||||
assert.deepEqual(splitEpisodeLabel('OVA - Beach Episode'), {
|
||||
number: null,
|
||||
title: 'OVA - Beach Episode',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel(''), { number: null, title: null });
|
||||
});
|
||||
|
||||
test('buildStreamDisplayTitle emits a form guessit and the jimaku parser both read', () => {
|
||||
assert.equal(
|
||||
buildStreamDisplayTitle('Mushoku Tensei: Jobless Reincarnation', 3, 4, null),
|
||||
'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
);
|
||||
assert.equal(
|
||||
buildStreamDisplayTitle('My Teen Romantic Comedy SNAFU Climax!', null, 10, 'Gallantly'),
|
||||
'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
|
||||
);
|
||||
assert.equal(buildStreamDisplayTitle('Some Movie', null, null, null), 'Some Movie');
|
||||
});
|
||||
|
||||
test('buildAnimeStreamStatsPath is stable across playbacks of the same episode', () => {
|
||||
const first = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
|
||||
const second = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
|
||||
assert.equal(first, second);
|
||||
assert.notEqual(first, buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-5'));
|
||||
assert.match(first, /^animebrowser:\/\//);
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata resolves the browser strings into fields', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '9001',
|
||||
animeUrl: '/anime/mushoku',
|
||||
animeTitle: 'Mushoku Tensei: Jobless Reincarnation Season 3',
|
||||
episodeUrl: '/watch/ep-4',
|
||||
episodeName: 'Episode 4',
|
||||
episodeNumber: 4,
|
||||
mediaPath: 'http://127.0.0.1:41234/video/abc123.m3u8',
|
||||
});
|
||||
|
||||
assert.equal(metadata.seriesTitle, 'Mushoku Tensei: Jobless Reincarnation');
|
||||
assert.equal(metadata.seasonNumber, 3);
|
||||
assert.equal(metadata.episodeNumber, 4);
|
||||
assert.equal(metadata.episodeTitle, null);
|
||||
assert.equal(metadata.displayTitle, 'Mushoku Tensei: Jobless Reincarnation S03E04');
|
||||
assert.equal(metadata.mediaPath, 'http://127.0.0.1:41234/video/abc123.m3u8');
|
||||
assert.equal(
|
||||
metadata.statsPath,
|
||||
buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4'),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata prefers the extension episode number over the label', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '1',
|
||||
animeUrl: '/a',
|
||||
animeTitle: 'Show',
|
||||
episodeUrl: '/e',
|
||||
episodeName: 'Finale',
|
||||
episodeNumber: 24,
|
||||
mediaPath: 'http://host/x.m3u8',
|
||||
});
|
||||
assert.equal(metadata.episodeNumber, 24);
|
||||
assert.equal(metadata.episodeTitle, 'Finale');
|
||||
assert.equal(metadata.displayTitle, 'Show E24 - Finale');
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata falls back to the label when the source reports no number', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '1',
|
||||
animeUrl: '/a',
|
||||
animeTitle: 'Show 2nd Season',
|
||||
episodeUrl: '/e',
|
||||
episodeName: 'Episode 3: Rain',
|
||||
episodeNumber: null,
|
||||
mediaPath: 'http://host/x.m3u8',
|
||||
});
|
||||
assert.equal(metadata.seasonNumber, 2);
|
||||
assert.equal(metadata.episodeNumber, 3);
|
||||
assert.equal(metadata.displayTitle, 'Show S02E03 - Rain');
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Structured metadata for a streamed episode.
|
||||
*
|
||||
* Extensions hand us two free-form strings — an anime title that usually
|
||||
* carries the season ("… Season 3") and an episode label that usually carries
|
||||
* the number ("Episode 4: …"). Everything downstream (stats grouping, AniList,
|
||||
* the subtitle modals) wants those as separate fields, so they are split once
|
||||
* here rather than re-parsed out of the mpv title by each consumer.
|
||||
*/
|
||||
|
||||
/** Where a stream came from, resolved into the fields consumers actually want. */
|
||||
export interface AnimeStreamMetadata {
|
||||
/** The URL handed to mpv. Matches what mpv reports as `path`. */
|
||||
mediaPath: string;
|
||||
/**
|
||||
* Stable identity for this episode. The stream URL carries a per-playback
|
||||
* proxy port and token, so it cannot be the key stats stores.
|
||||
*/
|
||||
statsPath: string;
|
||||
/** Series name with the season suffix removed. */
|
||||
seriesTitle: string;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
/** The episode's own name, or null when the label was only a number. */
|
||||
episodeTitle: string | null;
|
||||
/** Shown by mpv, and the fallback every string parser sees. */
|
||||
displayTitle: string;
|
||||
}
|
||||
|
||||
export interface AnimeStreamMetadataInput {
|
||||
sourceId: string;
|
||||
animeUrl: string;
|
||||
animeTitle: string;
|
||||
episodeUrl: string;
|
||||
episodeName: string;
|
||||
/** Extension-reported number; trusted over anything parsed from the label. */
|
||||
episodeNumber: number | null;
|
||||
/** The URL playback actually uses, after proxy rewriting. */
|
||||
mediaPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Season suffixes, anchored to the end of the title so a "Season" that is part
|
||||
* of the name ("A Season of Snow") cannot be mistaken for one.
|
||||
*/
|
||||
const SEASON_SUFFIX_PATTERNS: RegExp[] = [
|
||||
/[\s:_-]+season\s*(\d{1,2})\s*$/i,
|
||||
/[\s:_-]+(\d{1,2})(?:st|nd|rd|th)\s+season\s*$/i,
|
||||
/[\s:_-]+s(\d{1,2})\s*$/i,
|
||||
/[\s:_-]*第\s*(\d{1,2})\s*期\s*$/,
|
||||
/[\s:_-]+(\d{1,2})\s*期\s*$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Episode labels, most specific first. The trailing group is the episode's own
|
||||
* name when the label carries one.
|
||||
*/
|
||||
const EPISODE_LABEL_PATTERNS: RegExp[] = [
|
||||
/^\s*(?:episodio|épisode|episode|ep|e)\s*[.#]?\s*(\d{1,4}(?:\.\d+)?)\s*(?:[:\-–—.)]+\s*(.*))?$/i,
|
||||
/^\s*第\s*(\d{1,4})\s*話\s*(?:[:\-–—]+\s*)?(.*)$/,
|
||||
/^\s*(\d{1,4}(?:\.\d+)?)\s*[:\-–—.)]+\s*(.*)$/,
|
||||
/^\s*(\d{1,4}(?:\.\d+)?)\s*$/,
|
||||
];
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims separators a split left dangling on either end. `.` is deliberately not
|
||||
* one of them: an episode name often ends in a full stop that belongs to it.
|
||||
*/
|
||||
function trimSeparators(value: string): string {
|
||||
return collapseWhitespace(value)
|
||||
.replace(/^[\s:_\-–—]+/, '')
|
||||
.replace(/[\s:_\-–—]+$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toEpisodeNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a trailing season marker off an anime title.
|
||||
*
|
||||
* "Mushoku Tensei: Jobless Reincarnation Season 3" becomes the series plus
|
||||
* season 3, which is what both AniList and the stats grouping key want. A title
|
||||
* with no marker is returned unchanged with a null season — season 1 is *not*
|
||||
* assumed, because "unknown" and "one" behave differently when grouping.
|
||||
*/
|
||||
export function splitSeasonFromTitle(animeTitle: string): {
|
||||
title: string;
|
||||
season: number | null;
|
||||
} {
|
||||
const normalized = collapseWhitespace(animeTitle);
|
||||
for (const pattern of SEASON_SUFFIX_PATTERNS) {
|
||||
const match = normalized.match(pattern);
|
||||
if (!match || match.index === undefined) continue;
|
||||
const season = Number.parseInt(match[1]!, 10);
|
||||
if (!Number.isInteger(season) || season <= 0) continue;
|
||||
const title = trimSeparators(normalized.slice(0, match.index));
|
||||
// A title that is *only* a season marker is not a title; keep the original.
|
||||
if (!title) continue;
|
||||
return { title, season };
|
||||
}
|
||||
return { title: normalized, season: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an episode label into its number and its own name.
|
||||
*
|
||||
* Sources are inconsistent here: "Episode 4", "4. Title", "第4話 タイトル" and a
|
||||
* bare "4" all show up. A label that matches nothing is treated as a pure
|
||||
* episode name, which is right for movies and specials.
|
||||
*/
|
||||
export function splitEpisodeLabel(episodeName: string): {
|
||||
number: number | null;
|
||||
title: string | null;
|
||||
} {
|
||||
const normalized = collapseWhitespace(episodeName);
|
||||
if (!normalized) return { number: null, title: null };
|
||||
|
||||
for (const pattern of EPISODE_LABEL_PATTERNS) {
|
||||
const match = normalized.match(pattern);
|
||||
if (!match) continue;
|
||||
const parsed = Number.parseFloat(match[1]!);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) continue;
|
||||
const title = trimSeparators(match[2] ?? '');
|
||||
return { number: parsed, title: title || null };
|
||||
}
|
||||
|
||||
return { number: null, title: normalized };
|
||||
}
|
||||
|
||||
function formatEpisodePart(value: number): string {
|
||||
return Number.isInteger(value) ? String(value).padStart(2, '0') : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The title mpv shows.
|
||||
*
|
||||
* `SxxEyy` is not just for looks: it is the one form both guessit and
|
||||
* SubMiner's own filename parser read reliably, so any consumer that only ever
|
||||
* sees the title string still lands on the right series and episode.
|
||||
*/
|
||||
export function buildStreamDisplayTitle(
|
||||
seriesTitle: string,
|
||||
season: number | null,
|
||||
episode: number | null,
|
||||
episodeTitle: string | null,
|
||||
): string {
|
||||
const parts: string[] = [seriesTitle];
|
||||
if (episode !== null) {
|
||||
parts.push(
|
||||
season !== null
|
||||
? `S${String(season).padStart(2, '0')}E${formatEpisodePart(episode)}`
|
||||
: `E${formatEpisodePart(episode)}`,
|
||||
);
|
||||
} else if (season !== null) {
|
||||
parts.push(`S${String(season).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
const head = parts.join(' ');
|
||||
return episodeTitle ? `${head} - ${episodeTitle}` : head;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-episode identity that survives across playbacks.
|
||||
*
|
||||
* The stream URL points at the strip proxy, whose port and token are minted per
|
||||
* playback, so keying stats on it makes every rewatch a new video. The source's
|
||||
* own episode url is stable, so that is what stats records instead — with the
|
||||
* real URL kept as an alias so mpv's path change still finds the row.
|
||||
*/
|
||||
export function buildAnimeStreamStatsPath(
|
||||
sourceId: string,
|
||||
animeUrl: string,
|
||||
episodeUrl: string,
|
||||
): string {
|
||||
const source = encodeURIComponent(sourceId || 'unknown');
|
||||
const anime = encodeURIComponent(animeUrl || 'unknown');
|
||||
const episode = encodeURIComponent(episodeUrl || 'unknown');
|
||||
return `animebrowser://${source}/${anime}/${episode}`;
|
||||
}
|
||||
|
||||
export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): AnimeStreamMetadata {
|
||||
const { title: seriesTitle, season } = splitSeasonFromTitle(input.animeTitle);
|
||||
const label = splitEpisodeLabel(input.episodeName);
|
||||
const episodeNumber = toEpisodeNumber(input.episodeNumber) ?? label.number;
|
||||
const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title);
|
||||
|
||||
return {
|
||||
mediaPath: input.mediaPath,
|
||||
statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl),
|
||||
seriesTitle,
|
||||
seasonNumber: season,
|
||||
episodeNumber,
|
||||
episodeTitle: label.title,
|
||||
// A source that gave us neither a number nor a name leaves the series title
|
||||
// alone rather than showing an empty suffix.
|
||||
displayTitle: displayTitle || collapseWhitespace(input.animeTitle),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { installExtension, looksLikeApk, removeExtension } from './extension-installer';
|
||||
import type { RepoExtension } from './extension-repo';
|
||||
|
||||
const PKG = 'eu.kanade.tachiyomi.animeextension.all.example';
|
||||
|
||||
function apkBytes(payload = 'APK-BODY'): Uint8Array {
|
||||
// APKs are zip archives, so they start with the PK local-file-header magic.
|
||||
return new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...new TextEncoder().encode(payload)]);
|
||||
}
|
||||
|
||||
function repoExtension(overrides: Partial<RepoExtension> = {}): RepoExtension {
|
||||
return {
|
||||
pkg: PKG,
|
||||
name: 'Example Source',
|
||||
lang: 'all',
|
||||
version: '1.2.3',
|
||||
versionCode: 12,
|
||||
nsfw: false,
|
||||
apkUrl: 'https://repo.example/anime/apk/example.apk',
|
||||
iconUrl: 'https://repo.example/anime/icon/example.png',
|
||||
repoUrl: 'https://repo.example/anime/index.min.json',
|
||||
sourceNames: ['Example'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function respondWith(bytes: Uint8Array, headers: Record<string, string> = {}): typeof fetch {
|
||||
// Uint8Array is a valid Response body at runtime; the DOM lib types disagree.
|
||||
const body = bytes as unknown as BodyInit;
|
||||
return (async () => new Response(body, { status: 200, headers })) as typeof fetch;
|
||||
}
|
||||
|
||||
test('looksLikeApk accepts the zip magic and rejects anything else', () => {
|
||||
assert.equal(looksLikeApk(apkBytes()), true);
|
||||
assert.equal(looksLikeApk(new TextEncoder().encode('<!DOCTYPE html>')), false);
|
||||
assert.equal(looksLikeApk(new Uint8Array([])), false);
|
||||
});
|
||||
|
||||
test('installExtension writes the apk named after its package', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const target = await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes()),
|
||||
});
|
||||
|
||||
assert.equal(target, path.join(dir, `${PKG}.apk`));
|
||||
assert.match((await readFile(target)).toString(), /APK-BODY/);
|
||||
});
|
||||
|
||||
test('installing again replaces the previous version in place', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes('OLD')),
|
||||
});
|
||||
await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
|
||||
fetchImpl: respondWith(apkBytes('NEW')),
|
||||
});
|
||||
|
||||
const contents = (await readFile(path.join(dir, `${PKG}.apk`))).toString();
|
||||
assert.match(contents, /NEW/);
|
||||
assert.doesNotMatch(contents, /OLD/);
|
||||
});
|
||||
|
||||
test('the extensions directory is created when missing', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const nested = path.join(root, 'does', 'not', 'exist');
|
||||
await installExtension({
|
||||
extensionsDir: nested,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes()),
|
||||
});
|
||||
assert.equal(existsSync(path.join(nested, `${PKG}.apk`)), true);
|
||||
});
|
||||
|
||||
test('a non-ok response is reported with the extension name', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
|
||||
|
||||
await assert.rejects(
|
||||
() => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
|
||||
/Example Source.*404/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a response that is not an apk is rejected rather than written', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
// A misconfigured repo commonly serves an HTML error page instead.
|
||||
const fetchImpl = respondWith(new TextEncoder().encode('<!DOCTYPE html><html>404</html>'));
|
||||
|
||||
await assert.rejects(
|
||||
() => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
|
||||
/did not download as an APK/,
|
||||
);
|
||||
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
|
||||
});
|
||||
|
||||
test('an oversized download is refused by the declared length', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = respondWith(apkBytes(), { 'content-length': '999999999' });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
});
|
||||
|
||||
test('an oversized download is refused even when the length header lies', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = respondWith(apkBytes('x'.repeat(4096)), { 'content-length': '10' });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
|
||||
});
|
||||
|
||||
test('the byte limit stops the read instead of buffering the whole body', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
let pushed = 0;
|
||||
// Endless body: if the limit were only checked after buffering, this hangs.
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pushed += 1;
|
||||
controller.enqueue(new Uint8Array(512));
|
||||
},
|
||||
});
|
||||
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
// Only enough chunks to cross the limit were ever read.
|
||||
assert.ok(pushed <= 4, `read ${pushed} chunks before aborting`);
|
||||
});
|
||||
|
||||
test('a failed reader cancellation does not hide the size-limit error', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
let cancellationAttempted = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(1025));
|
||||
},
|
||||
async cancel() {
|
||||
cancellationAttempted = true;
|
||||
throw new Error('cancel failed');
|
||||
},
|
||||
});
|
||||
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
assert.ok(cancellationAttempted, 'the reader was never cancelled');
|
||||
});
|
||||
|
||||
test('a failed staged write preserves the installed apk and removes the partial file', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const target = path.join(dir, `${PKG}.apk`);
|
||||
await writeFile(target, apkBytes('OLD'));
|
||||
let stagedPath = '';
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
|
||||
fetchImpl: respondWith(apkBytes('NEW')),
|
||||
fileIo: {
|
||||
mkdir: (dirPath) => mkdir(dirPath, { recursive: true }),
|
||||
async writeFile(filePath, bytes) {
|
||||
stagedPath = filePath;
|
||||
await writeFile(filePath, bytes.subarray(0, 5));
|
||||
throw new Error('simulated disk write failure');
|
||||
},
|
||||
rename,
|
||||
removeFile: (filePath) => rm(filePath, { force: true }),
|
||||
},
|
||||
}),
|
||||
/simulated disk write failure/,
|
||||
);
|
||||
|
||||
assert.match((await readFile(target)).toString(), /OLD/);
|
||||
assert.notEqual(stagedPath, target);
|
||||
assert.equal(existsSync(stagedPath), false);
|
||||
});
|
||||
|
||||
test('a package name carrying path separators cannot escape the extensions dir', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const dir = path.join(root, 'extensions');
|
||||
const escaping = `eu.kanade.tachiyomi.animeextension${path.sep}..${path.sep}..${path.sep}pwned`;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension({ pkg: escaping }),
|
||||
fetchImpl: respondWith(apkBytes()),
|
||||
}),
|
||||
/not a valid file name/,
|
||||
);
|
||||
assert.equal(existsSync(path.join(root, 'pwned.apk')), false);
|
||||
});
|
||||
|
||||
test('removeExtension deletes the file and tolerates a missing one', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const file = path.join(dir, `${PKG}.apk`);
|
||||
await writeFile(file, 'x');
|
||||
|
||||
await removeExtension(dir, PKG);
|
||||
assert.equal(existsSync(file), false);
|
||||
await removeExtension(dir, PKG);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { extensionFileName, type RepoExtension } from './extension-repo';
|
||||
|
||||
/**
|
||||
* Downloads extension APKs into the extensions directory.
|
||||
*
|
||||
* Only URLs that came from a repository index the user configured are ever
|
||||
* fetched; nothing here discovers or suggests sources.
|
||||
*/
|
||||
|
||||
export interface InstallExtensionOptions {
|
||||
extensionsDir: string;
|
||||
extension: RepoExtension;
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Guards against a mistyped repo serving something enormous. */
|
||||
maxBytes?: number;
|
||||
/** Cancels a stalled download; without it a hung repo blocks the install. */
|
||||
signal?: AbortSignal;
|
||||
/** Applied when no `signal` is given, so a download can never hang forever. */
|
||||
timeoutMs?: number;
|
||||
/** Injectable filesystem boundary for failure-path tests. */
|
||||
fileIo?: ExtensionInstallerFileIo;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallerFileIo {
|
||||
mkdir: (dir: string) => Promise<unknown>;
|
||||
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
rename: (from: string, to: string) => Promise<void>;
|
||||
removeFile: (filePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_IO: ExtensionInstallerFileIo = {
|
||||
mkdir: (dir) => mkdir(dir, { recursive: true }),
|
||||
writeFile: (filePath, bytes) => writeFile(filePath, bytes),
|
||||
rename,
|
||||
removeFile: (filePath) => rm(filePath, { force: true }),
|
||||
};
|
||||
|
||||
/** APKs are a few MB; anything far past that is not an extension. */
|
||||
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Generous enough for a large APK on a slow link, short of hanging forever. */
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
const APK_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" — APKs are zip archives.
|
||||
|
||||
export function looksLikeApk(bytes: Uint8Array): boolean {
|
||||
return APK_MAGIC.every((byte, index) => bytes[index] === byte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download one extension into `extensionsDir`, replacing any previous version.
|
||||
*
|
||||
* The file is named after the package so an update overwrites in place rather
|
||||
* than leaving two versions for the bridge to load.
|
||||
*/
|
||||
export async function installExtension(options: InstallExtensionOptions): Promise<string> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const fileIo = options.fileIo ?? DEFAULT_FILE_IO;
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
const signal = options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
|
||||
const response = await fetchImpl(options.extension.apkUrl, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Downloading ${options.extension.name} failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const declared = Number(response.headers.get('content-length') ?? '0');
|
||||
if (declared > maxBytes) {
|
||||
throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
|
||||
}
|
||||
|
||||
const bytes = await readBounded(response, maxBytes, options.extension.name);
|
||||
if (!looksLikeApk(bytes)) {
|
||||
throw new Error(`${options.extension.name} did not download as an APK.`);
|
||||
}
|
||||
|
||||
await fileIo.mkdir(options.extensionsDir);
|
||||
const target = resolveTarget(options.extensionsDir, options.extension.pkg);
|
||||
const staged = `${target}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fileIo.writeFile(staged, bytes);
|
||||
await fileIo.rename(staged, target);
|
||||
} finally {
|
||||
try {
|
||||
await fileIo.removeFile(staged);
|
||||
} catch {}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the body incrementally and stop the moment the limit is passed.
|
||||
*
|
||||
* Buffering first and measuring afterwards would let a repo that lies about
|
||||
* (or omits) `content-length` push an unbounded amount into memory before the
|
||||
* check ever runs.
|
||||
*/
|
||||
async function readBounded(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
name: string,
|
||||
): Promise<Uint8Array> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defence in depth against a repository index that smuggles path separators
|
||||
* into a package name: the write target must stay inside `extensionsDir`.
|
||||
*/
|
||||
function resolveTarget(extensionsDir: string, pkg: string): string {
|
||||
const root = path.resolve(extensionsDir);
|
||||
const target = path.resolve(root, extensionFileName(pkg));
|
||||
if (path.dirname(target) !== root) {
|
||||
throw new Error(`Refusing to install ${pkg}: the package name is not a valid file name.`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Delete an installed extension. Missing files are treated as already gone. */
|
||||
export async function removeExtension(extensionsDir: string, pkg: string): Promise<void> {
|
||||
await rm(resolveTarget(extensionsDir, pkg), { force: true });
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
extensionFileName,
|
||||
fetchRepoCatalogue,
|
||||
fetchRepoIndex,
|
||||
isValidRepoUrl,
|
||||
parseRepoIndex,
|
||||
repoBaseUrl,
|
||||
} from './extension-repo';
|
||||
|
||||
const INDEX = 'https://repo.example/anime/index.min.json';
|
||||
|
||||
function animeEntry(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
name: 'Aniyomi: Example Source',
|
||||
pkg: 'eu.kanade.tachiyomi.animeextension.all.example',
|
||||
apk: 'example-v1.2.3.apk',
|
||||
lang: 'all',
|
||||
code: 12,
|
||||
version: '1.2.3',
|
||||
nsfw: 0,
|
||||
sources: [{ name: 'Example', lang: 'en' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('any https url naming a json index is accepted', () => {
|
||||
assert.equal(isValidRepoUrl(INDEX), true);
|
||||
assert.equal(isValidRepoUrl(' ' + INDEX + ' '), true);
|
||||
// Repos are free to name the index; index.min.json is only a convention.
|
||||
assert.equal(isValidRepoUrl('https://repo.example/anime/index.json'), true);
|
||||
assert.equal(
|
||||
isValidRepoUrl('https://manatan-community.github.io/extensions/video.min.json'),
|
||||
true,
|
||||
);
|
||||
// Plain http would let a network attacker swap the APK list.
|
||||
assert.equal(isValidRepoUrl('http://repo.example/anime/index.min.json'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example/anime/'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example/index.min.json.txt'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example'), false);
|
||||
assert.equal(isValidRepoUrl(''), false);
|
||||
});
|
||||
|
||||
test('repoBaseUrl strips the index file name', () => {
|
||||
assert.equal(repoBaseUrl(INDEX), 'https://repo.example/anime');
|
||||
assert.equal(
|
||||
repoBaseUrl('https://manatan-community.github.io/extensions/video.min.json'),
|
||||
'https://manatan-community.github.io/extensions',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseRepoIndex builds apk and icon urls from the repo root', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
|
||||
|
||||
assert.equal(extension?.pkg, 'eu.kanade.tachiyomi.animeextension.all.example');
|
||||
assert.equal(extension?.apkUrl, 'https://repo.example/anime/apk/example-v1.2.3.apk');
|
||||
assert.equal(
|
||||
extension?.iconUrl,
|
||||
'https://repo.example/anime/icon/eu.kanade.tachiyomi.animeextension.all.example.png',
|
||||
);
|
||||
assert.equal(extension?.repoUrl, INDEX);
|
||||
assert.equal(extension?.versionCode, 12);
|
||||
assert.deepEqual(extension?.sourceNames, ['Example']);
|
||||
});
|
||||
|
||||
test('the Aniyomi name prefix is stripped', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
|
||||
assert.equal(extension?.name, 'Example Source');
|
||||
});
|
||||
|
||||
test('manga packages are excluded', () => {
|
||||
const entries = [animeEntry(), animeEntry({ pkg: 'eu.kanade.tachiyomi.extension.en.somemanga' })];
|
||||
const parsed = parseRepoIndex(INDEX, entries);
|
||||
assert.equal(parsed.length, 1);
|
||||
assert.match(parsed[0]!.pkg, /animeextension/);
|
||||
});
|
||||
|
||||
test('malformed entries are skipped rather than failing the repo', () => {
|
||||
const parsed = parseRepoIndex(INDEX, [
|
||||
null,
|
||||
'nonsense',
|
||||
animeEntry({ apk: undefined }),
|
||||
animeEntry({ pkg: undefined }),
|
||||
animeEntry(),
|
||||
]);
|
||||
assert.equal(parsed.length, 1);
|
||||
});
|
||||
|
||||
test('a package name that is not a plain identifier is rejected', () => {
|
||||
// The package name becomes the on-disk file name, and a repo index is
|
||||
// unauthenticated: path separators here would write outside the extensions
|
||||
// directory even though the prefix check passes.
|
||||
const parsed = parseRepoIndex(INDEX, [
|
||||
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension/../../../../etc/cron.d/x' }),
|
||||
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension\\..\\evil' }),
|
||||
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension.all.ok' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
parsed.map((extension) => extension.pkg),
|
||||
['eu.kanade.tachiyomi.animeextension.all.ok'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an apk file name with path characters is rejected', () => {
|
||||
const parsed = parseRepoIndex(INDEX, [animeEntry({ apk: '../../../etc/passwd' })]);
|
||||
assert.deepEqual(parsed, []);
|
||||
});
|
||||
|
||||
test('parseRepoIndex tolerates a non-array payload', () => {
|
||||
assert.deepEqual(parseRepoIndex(INDEX, { message: 'Not Found' }), []);
|
||||
assert.deepEqual(parseRepoIndex(INDEX, null), []);
|
||||
});
|
||||
|
||||
test('missing optional fields fall back to safe defaults', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [
|
||||
{ pkg: 'eu.kanade.tachiyomi.animeextension.all.bare', apk: 'bare.apk' },
|
||||
]);
|
||||
assert.equal(extension?.name, 'eu.kanade.tachiyomi.animeextension.all.bare');
|
||||
assert.equal(extension?.lang, 'all');
|
||||
assert.equal(extension?.versionCode, 0);
|
||||
assert.equal(extension?.nsfw, false);
|
||||
assert.deepEqual(extension?.sourceNames, []);
|
||||
});
|
||||
|
||||
test('nsfw is read from the numeric flag', () => {
|
||||
assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 1 })])[0]?.nsfw, true);
|
||||
assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 0 })])[0]?.nsfw, false);
|
||||
});
|
||||
|
||||
test('fetchRepoIndex rejects an invalid url before making a request', async () => {
|
||||
let called = false;
|
||||
const fetchImpl = (async () => {
|
||||
called = true;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
await assert.rejects(() => fetchRepoIndex('http://insecure/index.min.json', { fetchImpl }));
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('fetchRepoIndex surfaces a non-ok response', async () => {
|
||||
const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
|
||||
await assert.rejects(() => fetchRepoIndex(INDEX, { fetchImpl }), /404/);
|
||||
});
|
||||
|
||||
test('fetchRepoIndex applies a deadline when the caller supplies no signal', async () => {
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
receivedSignal = init?.signal instanceof AbortSignal ? init.signal : undefined;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
await fetchRepoIndex(INDEX, { fetchImpl, timeoutMs: 50 });
|
||||
|
||||
assert.ok(receivedSignal, 'repository request should receive a deadline signal');
|
||||
});
|
||||
|
||||
test('fetchRepoCatalogue merges repos and keeps the highest version code', async () => {
|
||||
const second = 'https://other.example/anime/index.min.json';
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === INDEX) {
|
||||
return new Response(JSON.stringify([animeEntry({ code: 12, version: '1.2.3' })]));
|
||||
}
|
||||
return new Response(JSON.stringify([animeEntry({ code: 20, version: '2.0.0' })]));
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([INDEX, second], { fetchImpl });
|
||||
|
||||
assert.equal(catalogue.extensions.length, 1);
|
||||
assert.equal(catalogue.extensions[0]?.versionCode, 20);
|
||||
assert.equal(catalogue.extensions[0]?.repoUrl, second);
|
||||
assert.deepEqual(catalogue.failures, []);
|
||||
});
|
||||
|
||||
test('one failing repo does not hide the others', async () => {
|
||||
const broken = 'https://broken.example/anime/index.min.json';
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
if (String(input) === broken) throw new Error('ENOTFOUND');
|
||||
return new Response(JSON.stringify([animeEntry()]));
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([broken, INDEX], { fetchImpl });
|
||||
|
||||
assert.equal(catalogue.extensions.length, 1);
|
||||
assert.equal(catalogue.failures.length, 1);
|
||||
assert.equal(catalogue.failures[0]?.repoUrl, broken);
|
||||
assert.match(catalogue.failures[0]?.error ?? '', /ENOTFOUND/);
|
||||
});
|
||||
|
||||
test('an empty repo list yields an empty catalogue without any request', async () => {
|
||||
let called = false;
|
||||
const fetchImpl = (async () => {
|
||||
called = true;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([], { fetchImpl });
|
||||
assert.deepEqual(catalogue, { extensions: [], failures: [] });
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('extensions are stored under their package name so updates replace in place', () => {
|
||||
assert.equal(
|
||||
extensionFileName('eu.kanade.tachiyomi.animeextension.all.example'),
|
||||
'eu.kanade.tachiyomi.animeextension.all.example.apk',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Client for Aniyomi-format extension repositories.
|
||||
*
|
||||
* SubMiner ships no repositories and performs no discovery. A repository only
|
||||
* exists once the user adds its index URL, and only extensions from those
|
||||
* repositories are ever listed or downloaded.
|
||||
*/
|
||||
|
||||
/** Aniyomi extension packages carry this prefix; manga packages are ignored. */
|
||||
const ANIME_PACKAGE_PREFIX = 'eu.kanade.tachiyomi.animeextension';
|
||||
|
||||
/**
|
||||
* A package name becomes the on-disk APK file name, and a repository index is
|
||||
* unauthenticated content the user pointed us at. Only plain dotted identifiers
|
||||
* are accepted, so nothing in an index can carry `/` or `..` into a file path.
|
||||
*/
|
||||
const PACKAGE_NAME_PATTERN = /^[A-Za-z0-9_.]+$/;
|
||||
|
||||
/** The APK file name is appended to the repo URL, so keep it a bare name. */
|
||||
const APK_FILE_NAME_PATTERN = /^[A-Za-z0-9_.+-]+$/;
|
||||
|
||||
/**
|
||||
* Repos are identified by their index URL. The file name is not fixed:
|
||||
* `index.min.json` is the Aniyomi convention, but repositories publish under
|
||||
* other names too (e.g. `video.min.json`), so only https and a `.json` file
|
||||
* name are required.
|
||||
*/
|
||||
const INDEX_URL_PATTERN = /^https:\/\/[^\s/]+(?:\/[^\s/]*)*\/[^\s/]+\.json$/;
|
||||
|
||||
export interface RepoExtension {
|
||||
/** Package name, the stable identity of an extension across versions. */
|
||||
pkg: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
version: string;
|
||||
/** Monotonic version code; the comparison basis for updates. */
|
||||
versionCode: number;
|
||||
nsfw: boolean;
|
||||
apkUrl: string;
|
||||
iconUrl: string;
|
||||
/** Index URL of the repo this came from. */
|
||||
repoUrl: string;
|
||||
/** Source names the package provides, when the index declares them. */
|
||||
sourceNames: string[];
|
||||
}
|
||||
|
||||
/** `true` when `url` is a usable Aniyomi index URL. */
|
||||
export function isValidRepoUrl(url: string): boolean {
|
||||
return INDEX_URL_PATTERN.test(url.trim());
|
||||
}
|
||||
|
||||
/** Strip the index file name to get the repo root. */
|
||||
export function repoBaseUrl(indexUrl: string): string {
|
||||
return indexUrl.trim().replace(/\/[^/]*$/, '');
|
||||
}
|
||||
|
||||
interface RawEntry {
|
||||
name?: unknown;
|
||||
pkg?: unknown;
|
||||
apk?: unknown;
|
||||
lang?: unknown;
|
||||
code?: unknown;
|
||||
version?: unknown;
|
||||
nsfw?: unknown;
|
||||
sources?: unknown;
|
||||
}
|
||||
|
||||
function readSourceNames(sources: unknown): string[] {
|
||||
if (!Array.isArray(sources)) return [];
|
||||
return sources
|
||||
.map((source) =>
|
||||
source !== null && typeof source === 'object'
|
||||
? (source as { name?: unknown }).name
|
||||
: undefined,
|
||||
)
|
||||
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an index payload into anime extensions.
|
||||
*
|
||||
* Entries that are malformed, or that are manga rather than anime packages,
|
||||
* are skipped rather than failing the whole repo.
|
||||
*/
|
||||
export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtension[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
const base = repoBaseUrl(indexUrl);
|
||||
|
||||
const extensions: RepoExtension[] = [];
|
||||
for (const raw of payload as RawEntry[]) {
|
||||
if (raw === null || typeof raw !== 'object') continue;
|
||||
const pkg = typeof raw.pkg === 'string' ? raw.pkg : '';
|
||||
const apk = typeof raw.apk === 'string' ? raw.apk : '';
|
||||
if (!pkg.startsWith(ANIME_PACKAGE_PREFIX) || !PACKAGE_NAME_PATTERN.test(pkg)) continue;
|
||||
if (apk.length === 0 || !APK_FILE_NAME_PATTERN.test(apk)) continue;
|
||||
|
||||
const versionCode = Number(raw.code);
|
||||
extensions.push({
|
||||
pkg,
|
||||
// Repo entries are prefixed "Aniyomi: "; the app supplies its own context.
|
||||
name: (typeof raw.name === 'string' ? raw.name : pkg).replace(/^Aniyomi:\s*/, ''),
|
||||
lang: typeof raw.lang === 'string' ? raw.lang : 'all',
|
||||
version: typeof raw.version === 'string' ? raw.version : '0',
|
||||
versionCode: Number.isFinite(versionCode) ? versionCode : 0,
|
||||
nsfw: Number(raw.nsfw) === 1,
|
||||
apkUrl: `${base}/apk/${apk}`,
|
||||
iconUrl: `${base}/icon/${pkg}.png`,
|
||||
repoUrl: indexUrl,
|
||||
sourceNames: readSourceNames(raw.sources),
|
||||
});
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
export interface FetchRepoOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
/** Applied when no signal is supplied, so one stalled repo cannot block the catalogue. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_REPO_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Fetch and parse one repository index. */
|
||||
export async function fetchRepoIndex(
|
||||
indexUrl: string,
|
||||
options: FetchRepoOptions = {},
|
||||
): Promise<RepoExtension[]> {
|
||||
if (!isValidRepoUrl(indexUrl)) {
|
||||
throw new Error(`Not a valid repository index URL: ${indexUrl}`);
|
||||
}
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const signal =
|
||||
options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_REPO_TIMEOUT_MS);
|
||||
const response = await fetchImpl(indexUrl.trim(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Repository returned ${response.status} for ${indexUrl}`);
|
||||
}
|
||||
return parseRepoIndex(indexUrl, await response.json());
|
||||
}
|
||||
|
||||
export interface RepoFetchFailure {
|
||||
repoUrl: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface RepoCatalogue {
|
||||
extensions: RepoExtension[];
|
||||
failures: RepoFetchFailure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every configured repository.
|
||||
*
|
||||
* When two repos publish the same package, the higher version code wins, so a
|
||||
* user's preferred repo ordering does not silently pin an older build.
|
||||
*/
|
||||
export async function fetchRepoCatalogue(
|
||||
indexUrls: string[],
|
||||
options: FetchRepoOptions = {},
|
||||
): Promise<RepoCatalogue> {
|
||||
const failures: RepoFetchFailure[] = [];
|
||||
const byPackage = new Map<string, RepoExtension>();
|
||||
|
||||
const results = await Promise.all(
|
||||
indexUrls.map(async (indexUrl) => {
|
||||
try {
|
||||
return { indexUrl, extensions: await fetchRepoIndex(indexUrl, options) };
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
repoUrl: indexUrl,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { indexUrl, extensions: [] as RepoExtension[] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { extensions } of results) {
|
||||
for (const extension of extensions) {
|
||||
const existing = byPackage.get(extension.pkg);
|
||||
if (!existing || extension.versionCode > existing.versionCode) {
|
||||
byPackage.set(extension.pkg, extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
extensions: [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
/** File name an extension is stored under, so updates replace in place. */
|
||||
export function extensionFileName(pkg: string): string {
|
||||
return `${pkg}.apk`;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
listExtensionSources,
|
||||
readInstalledExtensions,
|
||||
toBridgeSource,
|
||||
toInstalledExtensionViews,
|
||||
type ExtensionSource,
|
||||
type InstalledExtension,
|
||||
} from './extension-store';
|
||||
import type { AnimeBridgeClient } from './bridge-client';
|
||||
|
||||
async function makeExtensionDir(files: Record<string, string>): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-ext-'));
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
await writeFile(path.join(dir, name), contents);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function fakeClient(
|
||||
impl: (source: { fingerprint: string }) => Promise<unknown[]>,
|
||||
): AnimeBridgeClient {
|
||||
return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
|
||||
}
|
||||
|
||||
test('readInstalledExtensions fingerprints apks without holding their bytes', async () => {
|
||||
const dir = await makeExtensionDir({ 'my-source.apk': 'APK-BYTES' });
|
||||
const extensions = await readInstalledExtensions(dir);
|
||||
|
||||
assert.equal(extensions.length, 1);
|
||||
assert.equal(extensions[0]?.fallbackName, 'my-source');
|
||||
assert.equal(extensions[0]?.sha256, createHash('sha256').update('APK-BYTES').digest('hex'));
|
||||
});
|
||||
|
||||
test('the fingerprint changes when an apk is replaced in place', async () => {
|
||||
const dir = await makeExtensionDir({ 'my-source.apk': 'V1' });
|
||||
const before = (await readInstalledExtensions(dir))[0]?.sha256;
|
||||
await writeFile(path.join(dir, 'my-source.apk'), 'V2');
|
||||
const after = (await readInstalledExtensions(dir))[0]?.sha256;
|
||||
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
|
||||
test('toBridgeSource reads the apk only when the bridge asks for it', async () => {
|
||||
const dir = await makeExtensionDir({ 'lazy.apk': 'APK-BYTES' });
|
||||
const extension = (await readInstalledExtensions(dir))[0]!;
|
||||
|
||||
const bridgeSource = toBridgeSource(extension);
|
||||
assert.equal(bridgeSource.fingerprint, extension.sha256);
|
||||
assert.equal(Buffer.from(await bridgeSource.loadApkBase64(), 'base64').toString(), 'APK-BYTES');
|
||||
});
|
||||
|
||||
test('readInstalledExtensions ignores non-apk files and subdirectories', async () => {
|
||||
const dir = await makeExtensionDir({ 'a.apk': 'A', 'notes.txt': 'x', 'b.APK': 'B' });
|
||||
await mkdir(path.join(dir, 'nested.apk'), { recursive: true });
|
||||
|
||||
const names = (await readInstalledExtensions(dir)).map((e) => e.fallbackName);
|
||||
// Sorted, case-insensitive extension match, directories excluded.
|
||||
assert.deepEqual(names, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('readInstalledExtensions returns empty for a missing directory', async () => {
|
||||
assert.deepEqual(await readInstalledExtensions('/nonexistent/subminer/extensions'), []);
|
||||
});
|
||||
|
||||
test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
|
||||
const extension: InstalledExtension = { file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' };
|
||||
assert.equal(toBridgeSource(extension).sourceId, undefined);
|
||||
assert.equal(toBridgeSource(extension, 'src-1').sourceId, 'src-1');
|
||||
assert.equal(toBridgeSource(extension, 'src-1').fingerprint, 'hash-a');
|
||||
});
|
||||
|
||||
test('listExtensionSources flattens every source a factory apk provides', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
||||
];
|
||||
const client = fakeClient(async () => [
|
||||
{ id: 101, name: 'Source One', lang: 'en' },
|
||||
{ id: '102', name: 'Source Two', lang: 'ja' },
|
||||
]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
|
||||
assert.equal(sources.length, 2);
|
||||
// Numeric bridge ids are normalized and package-qualified for UI state.
|
||||
assert.equal(sources[0]?.id, 'multi:101');
|
||||
assert.equal(sources[0]?.bridgeId, '101');
|
||||
assert.equal(sources[0]?.name, 'Source One');
|
||||
assert.equal(sources[1]?.lang, 'ja');
|
||||
});
|
||||
|
||||
test('sources with the same bridge id in different packages have distinct runtime ids', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one' },
|
||||
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two' },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: 'shared', name: 'Source', lang: 'en' }]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
|
||||
assert.deepEqual(
|
||||
sources.map(({ id, bridgeId, pkg }) => ({ id, bridgeId, pkg })),
|
||||
[
|
||||
{ id: 'pkg.one:shared', bridgeId: 'shared', pkg: 'pkg.one' },
|
||||
{ id: 'pkg.two:shared', bridgeId: 'shared', pkg: 'pkg.two' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('listExtensionSources falls back to the file name and a default language', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a' },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
assert.equal(sources[0]?.name, 'my-ext');
|
||||
assert.equal(sources[0]?.lang, 'all');
|
||||
});
|
||||
|
||||
test('listExtensionSources drops descriptors with no usable id', async () => {
|
||||
const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
|
||||
const sources = await listExtensionSources(client, [
|
||||
{ file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' },
|
||||
]);
|
||||
assert.deepEqual(sources, []);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
||||
];
|
||||
const sources: ExtensionSource[] = [
|
||||
{ id: 'multi:1', bridgeId: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
||||
{ id: 'multi:2', bridgeId: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
|
||||
];
|
||||
|
||||
assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
|
||||
{ pkg: 'multi', name: 'One, Two', langs: ['en', 'ja'], sourceCount: 2, error: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
||||
];
|
||||
|
||||
// A broken APK is still installed, so it must stay listed and removable.
|
||||
assert.deepEqual(
|
||||
toInstalledExtensionViews(extensions, [], [{ pkg: 'broken', error: 'dex2jar failed' }]),
|
||||
[{ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'dex2jar failed' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('one broken extension does not hide the working ones', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
||||
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b' },
|
||||
];
|
||||
const failures: string[] = [];
|
||||
const client = fakeClient(async (source) => {
|
||||
if (source.fingerprint === 'hash-a') throw new Error('dex2jar failed');
|
||||
return [{ id: '7', name: 'Good Source', lang: 'en' }];
|
||||
});
|
||||
|
||||
const sources = await listExtensionSources(client, extensions, (extension) => {
|
||||
failures.push(extension.fallbackName);
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, ['broken']);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0]?.name, 'Good Source');
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import path from 'node:path';
|
||||
import type { AnimeBridgeClient } from './bridge-client';
|
||||
import type { BridgeSource } from './bridge-client';
|
||||
import type { ExtensionLoadFailure, InstalledExtensionView } from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* Anime extensions are Aniyomi APKs the user supplies. They are read from a
|
||||
* directory rather than fetched from a hardcoded catalogue, so which sources
|
||||
* exist is entirely the user's choice.
|
||||
*/
|
||||
|
||||
export interface InstalledExtension {
|
||||
/** Absolute path to the .apk. */
|
||||
file: string;
|
||||
/** File name without extension, used when the bridge reports no name. */
|
||||
fallbackName: string;
|
||||
/**
|
||||
* SHA-256 of the APK. Identifies the build rather than the slot, so the
|
||||
* bridge's extension-id cache misses after an in-place upgrade.
|
||||
*/
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ExtensionSource {
|
||||
/** Package-qualified id used by the UI and runtime. */
|
||||
id: string;
|
||||
/** Raw bridge id, which selects this source inside a factory APK. */
|
||||
bridgeId: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
pkg: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover every .apk in `directory`. A missing directory yields no extensions.
|
||||
*
|
||||
* Only a hash is kept, never the bytes: APKs run to several MB each and a
|
||||
* base64 copy adds a third on top, so holding the whole set for the lifetime of
|
||||
* the Anime Browser would cost far more than re-reading a file on the rare
|
||||
* upload. Hashing streams, so peak memory stays flat regardless of APK size.
|
||||
*/
|
||||
export async function readInstalledExtensions(directory: string): Promise<InstalledExtension[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extensions: InstalledExtension[] = [];
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
|
||||
const file = path.join(directory, entry.name);
|
||||
extensions.push({
|
||||
file,
|
||||
fallbackName: entry.name.replace(/\.apk$/i, ''),
|
||||
sha256: await hashFile(file),
|
||||
});
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
async function hashFile(file: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(createReadStream(file), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe what is on disk, for the installed list in the Extensions tab.
|
||||
*
|
||||
* Built from the directory rather than from a repository catalogue: an APK
|
||||
* dropped in by hand, or one whose repository the user has since removed, is
|
||||
* still installed and must stay removable.
|
||||
*/
|
||||
export function toInstalledExtensionViews(
|
||||
extensions: InstalledExtension[],
|
||||
sources: ExtensionSource[],
|
||||
loadFailures: ExtensionLoadFailure[],
|
||||
): InstalledExtensionView[] {
|
||||
return extensions.map((extension) => {
|
||||
const provided = sources.filter((source) => source.file === extension.file);
|
||||
const names = [...new Set(provided.map((source) => source.name))];
|
||||
return {
|
||||
pkg: extension.fallbackName,
|
||||
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
|
||||
langs: [...new Set(provided.map((source) => source.lang))],
|
||||
sourceCount: provided.length,
|
||||
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge payload for a specific source inside an extension.
|
||||
*
|
||||
* The APK is read on demand: after the first upload the bridge answers by
|
||||
* extension id, so most calls never touch the file at all.
|
||||
*/
|
||||
export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
|
||||
return {
|
||||
fingerprint: extension.sha256,
|
||||
loadApkBase64: async () => (await readFile(extension.file)).toString('base64'),
|
||||
...(sourceId ? { sourceId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the bridge which sources each extension provides.
|
||||
*
|
||||
* An extension that fails to load is skipped rather than aborting the scan, so
|
||||
* one broken APK cannot hide every working one. Failures are reported through
|
||||
* `onError` for surfacing in the UI.
|
||||
*/
|
||||
export async function listExtensionSources(
|
||||
client: AnimeBridgeClient,
|
||||
extensions: InstalledExtension[],
|
||||
onError?: (extension: InstalledExtension, error: unknown) => void,
|
||||
): Promise<ExtensionSource[]> {
|
||||
const sources: ExtensionSource[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
const descriptors = await client.listAnimeSources(toBridgeSource(extension));
|
||||
for (const descriptor of descriptors) {
|
||||
const bridgeId = descriptor.id === undefined ? null : String(descriptor.id);
|
||||
if (bridgeId === null || bridgeId.length === 0) continue;
|
||||
sources.push({
|
||||
id: `${extension.fallbackName}:${bridgeId}`,
|
||||
bridgeId,
|
||||
name: descriptor.name?.trim() || extension.fallbackName,
|
||||
lang: descriptor.lang ?? 'all',
|
||||
pkg: extension.fallbackName,
|
||||
file: extension.file,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(extension, error);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseOkHttpHeaders, resolveStream, toMpvHeaderFields } from './headers';
|
||||
|
||||
test('parseOkHttpHeaders flattens the alternating name/value array', () => {
|
||||
const parsed = parseOkHttpHeaders({
|
||||
namesAndValues$okhttp: ['Referer', 'https://origin.example/', 'User-Agent', 'Aniyomi'],
|
||||
});
|
||||
assert.deepEqual(parsed, {
|
||||
Referer: 'https://origin.example/',
|
||||
'User-Agent': 'Aniyomi',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseOkHttpHeaders tolerates missing, empty, and odd-length input', () => {
|
||||
assert.deepEqual(parseOkHttpHeaders(undefined), {});
|
||||
assert.deepEqual(parseOkHttpHeaders({}), {});
|
||||
assert.deepEqual(parseOkHttpHeaders({ namesAndValues$okhttp: [] }), {});
|
||||
// A trailing name with no value is dropped rather than mapped to undefined.
|
||||
assert.deepEqual(parseOkHttpHeaders({ namesAndValues$okhttp: ['Referer'] }), {});
|
||||
});
|
||||
|
||||
test('toMpvHeaderFields joins entries and escapes commas in values', () => {
|
||||
const fields = toMpvHeaderFields({
|
||||
Referer: 'https://origin.example/',
|
||||
Cookie: 'a=1, b=2',
|
||||
});
|
||||
assert.equal(fields, 'Referer: https://origin.example/,Cookie: a=1\\, b=2');
|
||||
});
|
||||
|
||||
test('toMpvHeaderFields escapes backslashes so a trailing one cannot eat the separator', () => {
|
||||
const fields = toMpvHeaderFields({ Referer: 'https://origin.example/path\\', Cookie: 'a=1' });
|
||||
// Without doubling, the value's trailing backslash would escape the comma
|
||||
// and merge Cookie into the Referer entry.
|
||||
assert.equal(fields, 'Referer: https://origin.example/path\\\\,Cookie: a=1');
|
||||
});
|
||||
|
||||
test('toMpvHeaderFields returns an empty string when there are no headers', () => {
|
||||
assert.equal(toMpvHeaderFields({}), '');
|
||||
});
|
||||
|
||||
test('resolveStream normalizes a bridge video into a playable stream', () => {
|
||||
const stream = resolveStream({
|
||||
url: 'https://origin.example/embed/1',
|
||||
quality: '1080p',
|
||||
videoUrl: 'http://127.0.0.1:8080/video/master-token',
|
||||
headers: { namesAndValues$okhttp: ['Referer', 'https://origin.example/'] },
|
||||
subtitleTracks: [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: 'English' }],
|
||||
audioTracks: [{ url: 'http://127.0.0.1:8080/video/audio-token', lang: 'Japanese' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(stream, {
|
||||
url: 'http://127.0.0.1:8080/video/master-token',
|
||||
quality: '1080p',
|
||||
headers: { Referer: 'https://origin.example/' },
|
||||
subtitles: [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: 'English' }],
|
||||
audios: [{ url: 'http://127.0.0.1:8080/video/audio-token', lang: 'Japanese' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveStream returns null when the extension resolved no media url', () => {
|
||||
assert.equal(resolveStream({ url: 'https://origin.example/embed/1', quality: '1080p' }), null);
|
||||
assert.equal(resolveStream({ videoUrl: '' }), null);
|
||||
});
|
||||
|
||||
test('resolveStream drops tracks without a url and defaults a missing lang', () => {
|
||||
const stream = resolveStream({
|
||||
videoUrl: 'http://127.0.0.1:8080/video/master-token',
|
||||
subtitleTracks: [{ lang: 'English' }, { url: 'http://127.0.0.1:8080/video/sub-token' }],
|
||||
});
|
||||
assert.deepEqual(stream?.subtitles, [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: '' }]);
|
||||
assert.equal(stream?.quality, '');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { BridgeVideo, OkHttpHeaders, ResolvedStream } from './types';
|
||||
|
||||
/**
|
||||
* Flatten OkHttp's alternating `[name, value, name, value]` array into a map.
|
||||
* A trailing name with no value is dropped rather than mapped to undefined.
|
||||
*/
|
||||
export function parseOkHttpHeaders(headers: OkHttpHeaders | undefined): Record<string, string> {
|
||||
const flat = headers?.['namesAndValues$okhttp'];
|
||||
if (!Array.isArray(flat)) return {};
|
||||
|
||||
const parsed: Record<string, string> = {};
|
||||
for (let i = 0; i + 1 < flat.length; i += 2) {
|
||||
const name = flat[i];
|
||||
const value = flat[i + 1];
|
||||
if (typeof name === 'string' && typeof value === 'string') parsed[name] = value;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render headers as mpv's `--http-header-fields` string list. mpv splits
|
||||
* entries on commas, so commas inside a value must be escaped — and the
|
||||
* backslash that does the escaping has to be escaped first, or a value ending
|
||||
* in `\` would neutralise the separator and swallow the next header.
|
||||
*/
|
||||
export function toMpvHeaderFields(headers: Record<string, string>): string {
|
||||
return Object.entries(headers)
|
||||
.map(([name, value]) => `${name}: ${value.replace(/\\/g, '\\\\').replace(/,/g, '\\,')}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function normalizeTracks(
|
||||
tracks: Array<{ url?: string; lang?: string }> | undefined,
|
||||
): Array<{ url: string; lang: string }> {
|
||||
if (!Array.isArray(tracks)) return [];
|
||||
return tracks
|
||||
.filter((track): track is { url: string; lang?: string } => typeof track.url === 'string')
|
||||
.map((track) => ({ url: track.url, lang: track.lang ?? '' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a bridge video into a playable stream. Returns null when the
|
||||
* extension produced no `videoUrl`, which happens for entries it failed to
|
||||
* resolve.
|
||||
*/
|
||||
export function resolveStream(video: BridgeVideo): ResolvedStream | null {
|
||||
if (typeof video.videoUrl !== 'string' || video.videoUrl.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: video.videoUrl,
|
||||
quality: video.quality ?? '',
|
||||
headers: parseOkHttpHeaders(video.headers),
|
||||
subtitles: normalizeTracks(video.subtitleTracks),
|
||||
audios: normalizeTracks(video.audioTracks),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl, routeHlsThroughProxy } from './media-url';
|
||||
|
||||
const BRIDGE = 'http://127.0.0.1:56037';
|
||||
const PROXY = 'http://127.0.0.1:60001';
|
||||
|
||||
test('a bridge m3u8 stream is routed through the strip proxy', () => {
|
||||
assert.equal(
|
||||
routeHlsThroughProxy(`${BRIDGE}/video/master.m3u8?q=1080`, BRIDGE, PROXY),
|
||||
`${PROXY}/video/master.m3u8?q=1080`,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-HLS bridge streams stay on the bridge', () => {
|
||||
const direct = `${BRIDGE}/video/movie-token`;
|
||||
assert.equal(routeHlsThroughProxy(direct, BRIDGE, PROXY), direct);
|
||||
});
|
||||
|
||||
test('external m3u8 streams are not routed through the proxy', () => {
|
||||
const remote = 'https://cdn.example.com/hls/master.m3u8';
|
||||
assert.equal(routeHlsThroughProxy(remote, BRIDGE, PROXY), remote);
|
||||
});
|
||||
|
||||
test('unparseable stream urls pass through routeHlsThroughProxy unchanged', () => {
|
||||
assert.equal(routeHlsThroughProxy('not a url', BRIDGE, PROXY), 'not a url');
|
||||
assert.equal(
|
||||
routeHlsThroughProxy(`${BRIDGE}/video/a.m3u8`, 'garbage', PROXY),
|
||||
`${BRIDGE}/video/a.m3u8`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a loopback proxy url is rebased onto the live bridge port', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/image/cover-uuid'),
|
||||
'http://127.0.0.1:56037/image/cover-uuid',
|
||||
);
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://localhost:8080/video/master-token'),
|
||||
'http://127.0.0.1:56037/video/master-token',
|
||||
);
|
||||
});
|
||||
|
||||
test('query strings and fragments survive rebasing', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/video/token?quality=1080#t=30'),
|
||||
'http://127.0.0.1:56037/video/token?quality=1080#t=30',
|
||||
);
|
||||
});
|
||||
|
||||
test('ipv6 loopback is recognised', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://[::1]:8080/image/cover'),
|
||||
'http://127.0.0.1:56037/image/cover',
|
||||
);
|
||||
});
|
||||
|
||||
test('remote urls are left untouched', () => {
|
||||
const remote = 'https://cdn.example.com/covers/1.jpg';
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, remote), remote);
|
||||
});
|
||||
|
||||
test('loopback urls outside the proxy routes are left untouched', () => {
|
||||
// Only /image and /video are proxy routes; /capabilities is the server's own API.
|
||||
const other = 'http://127.0.0.1:8080/capabilities';
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, other), other);
|
||||
});
|
||||
|
||||
test('a base url without a scheme is assumed to be http', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl('127.0.0.1:56037', 'http://127.0.0.1:8080/image/cover'),
|
||||
'http://127.0.0.1:56037/image/cover',
|
||||
);
|
||||
});
|
||||
|
||||
test('unparseable input is returned unchanged rather than throwing', () => {
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, 'not a url'), 'not a url');
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl('', 'http://127.0.0.1:8080/image/c'),
|
||||
'http://127.0.0.1:8080/image/c',
|
||||
);
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, ''), '');
|
||||
});
|
||||
|
||||
test('parseAnimeStatus maps the SAnime constants', () => {
|
||||
assert.equal(parseAnimeStatus(1), 'ongoing');
|
||||
assert.equal(parseAnimeStatus(2), 'completed');
|
||||
assert.equal(parseAnimeStatus(4), 'publishing-finished');
|
||||
assert.equal(parseAnimeStatus(5), 'cancelled');
|
||||
assert.equal(parseAnimeStatus(6), 'on-hiatus');
|
||||
assert.equal(parseAnimeStatus(0), 'unknown');
|
||||
assert.equal(parseAnimeStatus(undefined), 'unknown');
|
||||
// 3 is unused in the SAnime constants.
|
||||
assert.equal(parseAnimeStatus(3), 'unknown');
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The bridge returns cover art and video URLs pointing at its own loopback
|
||||
* media proxy, but the origin it embeds is not always the port we actually
|
||||
* started it on. Rebase those onto the live bridge origin, and leave any
|
||||
* genuinely remote URL untouched.
|
||||
*/
|
||||
|
||||
const PROXY_ROUTES = new Set(['image', 'video']);
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
||||
|
||||
function isLoopbackProxyUrl(candidate: URL): boolean {
|
||||
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') return false;
|
||||
const host = candidate.hostname.toLowerCase();
|
||||
if (!LOOPBACK_HOSTS.has(host)) return false;
|
||||
const route = candidate.pathname.split('/').filter(Boolean)[0];
|
||||
return route !== undefined && PROXY_ROUTES.has(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a bridge media URL onto `bridgeBaseUrl`, preserving path and query.
|
||||
* Returns the input unchanged when it is not a loopback proxy URL, or when
|
||||
* either URL cannot be parsed.
|
||||
*/
|
||||
export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string): string {
|
||||
let media: URL;
|
||||
try {
|
||||
media = new URL(mediaUrl);
|
||||
} catch {
|
||||
return mediaUrl;
|
||||
}
|
||||
if (!isLoopbackProxyUrl(media)) return mediaUrl;
|
||||
|
||||
const normalizedBase = bridgeBaseUrl.includes('://') ? bridgeBaseUrl : `http://${bridgeBaseUrl}`;
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(normalizedBase);
|
||||
} catch {
|
||||
return mediaUrl;
|
||||
}
|
||||
if (base.protocol !== 'http:' && base.protocol !== 'https:') return mediaUrl;
|
||||
if (base.hostname.length === 0) return mediaUrl;
|
||||
|
||||
const rebased = new URL(base.origin);
|
||||
rebased.pathname = media.pathname;
|
||||
rebased.search = media.search;
|
||||
rebased.hash = media.hash;
|
||||
return rebased.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a bridge-served HLS stream through the local strip proxy instead. Only
|
||||
* `.m3u8` URLs on the bridge origin qualify: direct files need no fixing, and
|
||||
* an external URL would not resolve through a proxy that forwards to the
|
||||
* bridge. Anything unparseable comes back unchanged.
|
||||
*/
|
||||
export function routeHlsThroughProxy(
|
||||
streamUrl: string,
|
||||
bridgeBaseUrl: string,
|
||||
proxyOrigin: string,
|
||||
): string {
|
||||
let stream: URL;
|
||||
let bridge: URL;
|
||||
try {
|
||||
stream = new URL(streamUrl);
|
||||
bridge = new URL(bridgeBaseUrl);
|
||||
} catch {
|
||||
return streamUrl;
|
||||
}
|
||||
if (stream.origin !== bridge.origin) return streamUrl;
|
||||
if (!stream.pathname.endsWith('.m3u8')) return streamUrl;
|
||||
return `${proxyOrigin}${stream.pathname}${stream.search}`;
|
||||
}
|
||||
|
||||
/** Aniyomi's SAnime status constants. */
|
||||
export type AnimeStatus =
|
||||
| 'unknown'
|
||||
| 'ongoing'
|
||||
| 'completed'
|
||||
| 'publishing-finished'
|
||||
| 'cancelled'
|
||||
| 'on-hiatus';
|
||||
|
||||
export function parseAnimeStatus(status: number | undefined): AnimeStatus {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'ongoing';
|
||||
case 2:
|
||||
return 'completed';
|
||||
case 4:
|
||||
return 'publishing-finished';
|
||||
case 5:
|
||||
return 'cancelled';
|
||||
case 6:
|
||||
return 'on-hiatus';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildLoadfileOptions,
|
||||
buildPlaybackCommands,
|
||||
buildTrackCommands,
|
||||
normalizeLangTag,
|
||||
selectPreferredStream,
|
||||
} from './mpv-playback';
|
||||
import type { ResolvedStream } from './types';
|
||||
|
||||
function stream(overrides: Partial<ResolvedStream> = {}): ResolvedStream {
|
||||
return {
|
||||
url: 'http://127.0.0.1:8080/video/token',
|
||||
quality: '1080p',
|
||||
headers: {},
|
||||
subtitles: [],
|
||||
audios: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('loadfile options keep one visible track and never scan the filesystem', () => {
|
||||
const options = buildLoadfileOptions({ stream: stream() });
|
||||
for (const expected of [
|
||||
'sub-auto=no',
|
||||
'secondary-sid=no',
|
||||
'secondary-sub-visibility=no',
|
||||
'sub-visibility=yes',
|
||||
]) {
|
||||
assert.ok(options.split(',').includes(expected), `missing ${expected}`);
|
||||
}
|
||||
// The source's own subtitles are the only ones this path gets, so they must
|
||||
// not be suppressed the way the Jellyfin path suppresses them.
|
||||
assert.ok(!options.split(',').includes('sid=no'));
|
||||
// Comma-separated values would split the option list, so the language
|
||||
// preferences ride as properties instead.
|
||||
assert.ok(!options.includes('alang'));
|
||||
assert.ok(!options.includes('slang'));
|
||||
});
|
||||
|
||||
test('headers are percent-escaped so their commas do not split the option list', () => {
|
||||
const headers = { Referer: 'https://a.test/', 'User-Agent': 'X' };
|
||||
const options = buildLoadfileOptions({ stream: stream({ headers }) });
|
||||
|
||||
// Verified against mpv 0.41: the unescaped form yields an empty header list.
|
||||
const fields = 'Referer: https://a.test/,User-Agent: X';
|
||||
assert.ok(options.includes(`http-header-fields=%${fields.length}%${fields}`));
|
||||
});
|
||||
|
||||
test('the escape length counts the full header string including separators', () => {
|
||||
const options = buildLoadfileOptions({
|
||||
stream: stream({ headers: { Cookie: 'a=1, b=2' } }),
|
||||
});
|
||||
// The comma inside the value is backslash-escaped first, so the length grows.
|
||||
const fields = 'Cookie: a=1\\, b=2';
|
||||
assert.ok(options.includes(`%${fields.length}%${fields}`));
|
||||
});
|
||||
|
||||
test('the escape length is counted in utf-8 bytes, not js string units', () => {
|
||||
// An extension may put a non-ASCII value in a header; mpv reads %n% as a
|
||||
// byte count, so counting string units would truncate the value.
|
||||
const options = buildLoadfileOptions({
|
||||
stream: stream({ headers: { 'X-Title': '日本語' } }),
|
||||
});
|
||||
const fields = 'X-Title: 日本語';
|
||||
assert.ok(options.includes(`%${Buffer.byteLength(fields, 'utf8')}%${fields}`));
|
||||
assert.ok(!options.includes(`%${fields.length}%`));
|
||||
});
|
||||
|
||||
test('no header option is emitted when the stream carries no headers', () => {
|
||||
const options = buildLoadfileOptions({ stream: stream() });
|
||||
assert.ok(!options.includes('http-header-fields'));
|
||||
});
|
||||
|
||||
test('a positive start position is appended, zero is omitted', () => {
|
||||
assert.ok(buildLoadfileOptions({ stream: stream(), startSeconds: 42 }).includes('start=42'));
|
||||
assert.ok(!buildLoadfileOptions({ stream: stream(), startSeconds: 0 }).includes('start='));
|
||||
assert.ok(!buildLoadfileOptions({ stream: stream() }).includes('start='));
|
||||
});
|
||||
|
||||
test('playback commands set the language preference before loading the file', () => {
|
||||
const commands = buildPlaybackCommands({ stream: stream(), title: 'Example - 01' });
|
||||
|
||||
assert.deepEqual(commands[0], ['script-message', 'subminer-managed-subtitles-loading']);
|
||||
// Japanese first, so a multi-audio stream never starts on the dub. slang is
|
||||
// Japanese-only: an English track belongs in the secondary slot, which the
|
||||
// secondarySub auto-load fills by language tag.
|
||||
assert.deepEqual(commands[1], ['set_property', 'alang', 'ja,jpn,jp,japanese']);
|
||||
assert.deepEqual(commands[2], ['set_property', 'slang', 'ja,jpn,jp,japanese']);
|
||||
assert.equal(commands[3]?.[0], 'loadfile');
|
||||
assert.equal(commands[3]?.[1], 'http://127.0.0.1:8080/video/token');
|
||||
assert.equal(commands[3]?.[2], 'replace');
|
||||
assert.equal(commands[3]?.[3], -1);
|
||||
assert.deepEqual(commands[4], ['set_property', 'force-media-title', 'Example - 01']);
|
||||
});
|
||||
|
||||
test('force-media-title is skipped when there is no title', () => {
|
||||
assert.equal(buildPlaybackCommands({ stream: stream() }).length, 4);
|
||||
assert.equal(buildPlaybackCommands({ stream: stream(), title: '' }).length, 4);
|
||||
});
|
||||
|
||||
test('external audio tracks are added, with the Japanese one selected', () => {
|
||||
const commands = buildTrackCommands(
|
||||
stream({
|
||||
audios: [
|
||||
{ url: 'http://host/en.m4a', lang: 'en' },
|
||||
{ url: 'http://host/ja.m4a', lang: 'ja' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['audio-add', 'http://host/en.m4a', 'auto', 'en', 'en'],
|
||||
['audio-add', 'http://host/ja.m4a', 'select', 'ja', 'ja'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('external audio is left unselected when none of it is Japanese', () => {
|
||||
// alang already picked a track off the container; do not override it.
|
||||
const commands = buildTrackCommands(
|
||||
stream({ audios: [{ url: 'http://host/en.m4a', lang: 'eng' }] }),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [['audio-add', 'http://host/en.m4a', 'auto', 'eng', 'en']]);
|
||||
});
|
||||
|
||||
test('only a Japanese subtitle track is selected as primary', () => {
|
||||
const japanese = buildTrackCommands(
|
||||
stream({
|
||||
subtitles: [
|
||||
{ url: 'http://host/en.vtt', lang: 'English' },
|
||||
{ url: 'http://host/ja.vtt', lang: 'Japanese' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(japanese[1], ['sub-add', 'http://host/ja.vtt', 'select', 'Japanese', 'ja']);
|
||||
assert.equal(japanese[0]?.[2], 'auto');
|
||||
|
||||
// English is the user's *secondary* language; it must not take the primary
|
||||
// slot. It rides in unselected, tagged so the secondarySub auto-load can
|
||||
// route it to secondary-sid.
|
||||
const englishOnly = buildTrackCommands(
|
||||
stream({ subtitles: [{ url: 'http://host/en.vtt', lang: 'English' }] }),
|
||||
);
|
||||
assert.deepEqual(englishOnly, [['sub-add', 'http://host/en.vtt', 'auto', 'English', 'en']]);
|
||||
});
|
||||
|
||||
test('language labels normalize to the tags users configure', () => {
|
||||
assert.equal(normalizeLangTag('English'), 'en');
|
||||
assert.equal(normalizeLangTag('eng'), 'en');
|
||||
assert.equal(normalizeLangTag('en-US'), 'en');
|
||||
assert.equal(normalizeLangTag('Japanese'), 'ja');
|
||||
assert.equal(normalizeLangTag('jpn'), 'ja');
|
||||
assert.equal(normalizeLangTag('Português'), 'pt');
|
||||
// Unknown labels pass through untouched rather than being guessed at.
|
||||
assert.equal(normalizeLangTag('Klingon'), 'Klingon');
|
||||
assert.equal(normalizeLangTag(''), '');
|
||||
});
|
||||
|
||||
test('unlabelled tracks still get a usable menu title, duplicates are dropped', () => {
|
||||
const commands = buildTrackCommands(
|
||||
stream({
|
||||
subtitles: [
|
||||
{ url: 'http://host/a.vtt', lang: '' },
|
||||
{ url: 'http://host/a.vtt', lang: '' },
|
||||
{ url: 'http://host/b.vtt', lang: '' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['sub-add', 'http://host/a.vtt', 'auto', 'Subtitle 1', ''],
|
||||
['sub-add', 'http://host/b.vtt', 'auto', 'Subtitle 2', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a stream with no external tracks emits no track commands', () => {
|
||||
assert.deepEqual(buildTrackCommands(stream()), []);
|
||||
});
|
||||
|
||||
test('selectPreferredStream skips dub entries in favour of the original audio', () => {
|
||||
const streams = [
|
||||
stream({ quality: '1080p (Dub)' }),
|
||||
stream({ quality: '720p (Sub)' }),
|
||||
stream({ quality: '480p (Dub)' }),
|
||||
];
|
||||
|
||||
// Language beats the quality hint: a 1080p dub is the wrong file, not a
|
||||
// better one.
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '720p (Sub)');
|
||||
assert.equal(selectPreferredStream(streams, '1080')?.quality, '720p (Sub)');
|
||||
});
|
||||
|
||||
test('selectPreferredStream prefers an entry carrying a Japanese audio track', () => {
|
||||
const streams = [
|
||||
stream({ quality: '1080p' }),
|
||||
stream({ quality: '720p', audios: [{ url: 'http://host/ja.m4a', lang: 'ja' }] }),
|
||||
];
|
||||
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '720p');
|
||||
});
|
||||
|
||||
test('an all-dub list still plays rather than failing', () => {
|
||||
const streams = [stream({ quality: '1080p Dub' }), stream({ quality: '720p Dub' })];
|
||||
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '1080p Dub');
|
||||
assert.equal(selectPreferredStream(streams, '720')?.quality, '720p Dub');
|
||||
});
|
||||
|
||||
test('selectPreferredStream honours a quality hint, else takes the first', () => {
|
||||
const streams = [stream({ quality: '360p' }), stream({ quality: '1080p' })];
|
||||
|
||||
assert.equal(selectPreferredStream(streams, '1080')?.quality, '1080p');
|
||||
assert.equal(selectPreferredStream(streams, '1080P')?.quality, '1080p');
|
||||
// Extensions label streams with the host name, so the hint matches a substring.
|
||||
const decorated = [
|
||||
stream({ quality: 'Doodstream - 360p' }),
|
||||
stream({ quality: 'Vidhide - 720p' }),
|
||||
];
|
||||
assert.equal(selectPreferredStream(decorated, '720')?.quality, 'Vidhide - 720p');
|
||||
// Extensions pre-sort by their own preference, so the first entry wins.
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '360p');
|
||||
// A hint that matches nothing falls back rather than failing.
|
||||
assert.equal(selectPreferredStream(streams, '4k')?.quality, '360p');
|
||||
});
|
||||
|
||||
test('selectPreferredStream returns null for an empty list', () => {
|
||||
assert.equal(selectPreferredStream([]), null);
|
||||
assert.equal(selectPreferredStream([], '1080p'), null);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { toMpvHeaderFields } from './headers';
|
||||
import type { ResolvedStream } from './types';
|
||||
|
||||
/**
|
||||
* Japanese first, always, and for subtitles Japanese *only*: the primary slot
|
||||
* belongs to the language being mined, and an English track belongs in the
|
||||
* secondary slot, where the `secondarySub` machinery puts it by language tag.
|
||||
* For audio, mpv falls back to the first track when nothing matches, so an
|
||||
* English-only release still plays.
|
||||
*/
|
||||
export const JAPANESE_LANGUAGE_PREFERENCE = 'ja,jpn,jp,japanese';
|
||||
|
||||
/**
|
||||
* mpv must not scan the filesystem for sidecar subtitles when the "file" is a
|
||||
* network stream, and the secondary slot stays empty so the overlay only ever
|
||||
* reads one track. Everything else is left to normal track selection, driven
|
||||
* by the language preferences above.
|
||||
*
|
||||
* `alang`/`slang` are set as properties instead of file-local options: their
|
||||
* values are comma-separated lists, and a comma inside a `loadfile` option
|
||||
* value splits the option list.
|
||||
*/
|
||||
const BASE_LOADFILE_OPTIONS = [
|
||||
'sub-auto=no',
|
||||
'secondary-sid=no',
|
||||
'secondary-sub-visibility=no',
|
||||
'sub-visibility=yes',
|
||||
];
|
||||
|
||||
/** Matches a language tag or a label such as "Japanese (Sub)" or "[JPN]". */
|
||||
const JAPANESE_PATTERN = /(^|[^a-z])(ja|jp|jpn|japanese|日本語)([^a-z]|$)/i;
|
||||
/** Extensions label dub entries in the quality string, e.g. "1080p (Dub)". */
|
||||
const DUB_PATTERN = /(^|[^a-z])(dub|dubbed|dublado|latino|castellano)([^a-z]|$)/i;
|
||||
/** The counterpart label for original-audio entries, e.g. "SUB - 1080p". */
|
||||
const SUBBED_PATTERN = /(^|[^a-z])(sub|subbed|softsub|hardsub|subtitulado|raw)([^a-z]|$)/i;
|
||||
|
||||
export type MpvCommand = Array<string | number>;
|
||||
|
||||
export interface BuildPlaybackOptions {
|
||||
stream: ResolvedStream;
|
||||
/** Shown as the mpv window/OSD title. */
|
||||
title?: string;
|
||||
/** Resume position in seconds. */
|
||||
startSeconds?: number;
|
||||
}
|
||||
|
||||
export function isJapaneseTag(value: string): boolean {
|
||||
return JAPANESE_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the mpv `loadfile` option string for a stream.
|
||||
*
|
||||
* Headers ride as `file-local-options/http-header-fields` so they apply to this
|
||||
* file only, and so SubMiner's Anki media path can read them back off mpv when
|
||||
* generating card audio and screenshots. Tracks added later with `sub-add` /
|
||||
* `audio-add` inherit them too, which is how external tracks on an
|
||||
* authenticated host stay reachable.
|
||||
*/
|
||||
export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
|
||||
const parts = [...BASE_LOADFILE_OPTIONS];
|
||||
|
||||
const headerFields = toMpvHeaderFields(options.stream.headers);
|
||||
if (headerFields.length > 0) {
|
||||
// Escape the mpv option-list separators so a header never splits the list.
|
||||
parts.push(`http-header-fields=${escapeOptionValue(headerFields)}`);
|
||||
}
|
||||
|
||||
if (options.startSeconds !== undefined && options.startSeconds > 0) {
|
||||
parts.push(`start=${options.startSeconds}`);
|
||||
}
|
||||
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* mpv splits `loadfile` options on commas and `=`-separates keys, so a value
|
||||
* containing either must be quoted. Percent-encoding is mpv's own escape for
|
||||
* embedded separators in option values.
|
||||
*
|
||||
* The count is in UTF-8 bytes of the decoded value, not JS string units, so a
|
||||
* non-ASCII header value (extensions supply these) would otherwise under-count
|
||||
* and mpv would cut the value short.
|
||||
*/
|
||||
function escapeOptionValue(value: string): string {
|
||||
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered mpv commands that start playback of a resolved stream.
|
||||
*
|
||||
* The plugin is told subtitles are being managed before the file loads, so the
|
||||
* overlay does not flash the source's own tracks during the swap.
|
||||
*/
|
||||
export function buildPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
|
||||
const commands: MpvCommand[] = [
|
||||
['script-message', 'subminer-managed-subtitles-loading'],
|
||||
['set_property', 'alang', JAPANESE_LANGUAGE_PREFERENCE],
|
||||
['set_property', 'slang', JAPANESE_LANGUAGE_PREFERENCE],
|
||||
['loadfile', options.stream.url, 'replace', -1, buildLoadfileOptions(options)],
|
||||
];
|
||||
|
||||
if (options.title !== undefined && options.title.length > 0) {
|
||||
commands.push(['set_property', 'force-media-title', options.title]);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands that attach the extension's external audio and subtitle tracks.
|
||||
*
|
||||
* These must be sent *after* the file is loading, so they are separate from
|
||||
* {@link buildPlaybackCommands}. Every track is added — even the ones we do not
|
||||
* select — so they show up in mpv's track menu and can be switched by hand.
|
||||
*/
|
||||
export function buildTrackCommands(stream: ResolvedStream): MpvCommand[] {
|
||||
return [
|
||||
...buildAddTrackCommands('audio-add', stream.audios, 'Audio'),
|
||||
...buildAddTrackCommands('sub-add', stream.subtitles, 'Subtitle'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a Japanese track is ever selected outright — the primary slot is for
|
||||
* the mining language. A non-Japanese track is added unselected: for audio,
|
||||
* `alang`'s pick off the container stands; for subtitles, the `secondarySub`
|
||||
* auto-load matches the track's language tag against the user's configured
|
||||
* secondary languages and routes it to `secondary-sid` instead.
|
||||
*/
|
||||
function buildAddTrackCommands(
|
||||
command: 'audio-add' | 'sub-add',
|
||||
tracks: Array<{ url: string; lang: string }>,
|
||||
kind: 'Audio' | 'Subtitle',
|
||||
): MpvCommand[] {
|
||||
const unique = dedupeByUrl(tracks);
|
||||
const selected = unique.findIndex((track) => isJapaneseTag(track.lang));
|
||||
|
||||
return unique.map((track, index) => [
|
||||
command,
|
||||
track.url,
|
||||
index === selected ? 'select' : 'auto',
|
||||
track.lang || `${kind} ${index + 1}`,
|
||||
normalizeLangTag(track.lang),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Extension language labels mapped to the tags users put in config. */
|
||||
const LANG_TAG_BY_LABEL: Record<string, string> = {
|
||||
japanese: 'ja',
|
||||
日本語: 'ja',
|
||||
english: 'en',
|
||||
eng: 'en',
|
||||
spanish: 'es',
|
||||
español: 'es',
|
||||
portuguese: 'pt',
|
||||
português: 'pt',
|
||||
french: 'fr',
|
||||
français: 'fr',
|
||||
german: 'de',
|
||||
deutsch: 'de',
|
||||
italian: 'it',
|
||||
italiano: 'it',
|
||||
indonesian: 'id',
|
||||
arabic: 'ar',
|
||||
russian: 'ru',
|
||||
korean: 'ko',
|
||||
chinese: 'zh',
|
||||
thai: 'th',
|
||||
vietnamese: 'vi',
|
||||
};
|
||||
|
||||
/**
|
||||
* mpv's `lang` field is what SubMiner's secondary-subtitle auto-load compares
|
||||
* against `secondarySub.secondarySubLanguages`, so a label like "English" must
|
||||
* become the tag a user would actually configure. Unknown labels pass through;
|
||||
* matching is best-effort, and the raw label stays visible as the track title.
|
||||
*/
|
||||
export function normalizeLangTag(lang: string): string {
|
||||
const trimmed = lang.trim();
|
||||
if (isJapaneseTag(trimmed)) return 'ja';
|
||||
const mapped = LANG_TAG_BY_LABEL[trimmed.toLowerCase()];
|
||||
if (mapped !== undefined) return mapped;
|
||||
if (/^[A-Za-z]{2,3}([-_][A-Za-z0-9]+)?$/.test(trimmed)) {
|
||||
return trimmed.split(/[-_]/, 1)[0]?.toLowerCase() ?? trimmed.toLowerCase();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function dedupeByUrl(
|
||||
tracks: Array<{ url: string; lang: string }>,
|
||||
): Array<{ url: string; lang: string }> {
|
||||
const seen = new Set<string>();
|
||||
return tracks.filter((track) => {
|
||||
if (track.url.length === 0 || seen.has(track.url)) return false;
|
||||
seen.add(track.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank a stream by how likely it is to carry Japanese audio.
|
||||
*
|
||||
* Sources commonly return the dub and the original as separate entries rather
|
||||
* than as two audio tracks of one entry, so the choice of *entry* is the first
|
||||
* place a dub can slip in.
|
||||
*/
|
||||
function scoreStream(stream: ResolvedStream): number {
|
||||
if (stream.audios.some((audio) => isJapaneseTag(audio.lang))) return 2;
|
||||
const label = stream.quality;
|
||||
if (isJapaneseTag(label) || SUBBED_PATTERN.test(label)) return 1;
|
||||
if (DUB_PATTERN.test(label)) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best stream from an extension's video list.
|
||||
*
|
||||
* Japanese audio outranks the quality hint — a 1080p dub is the wrong file, not
|
||||
* a better one. Within the surviving entries the hint decides, and otherwise
|
||||
* the extension's own ordering does.
|
||||
*/
|
||||
export function selectPreferredStream(
|
||||
streams: ResolvedStream[],
|
||||
preferredQuality?: string,
|
||||
): ResolvedStream | null {
|
||||
if (streams.length === 0) return null;
|
||||
|
||||
const best = Math.max(...streams.map(scoreStream));
|
||||
const candidates = streams.filter((stream) => scoreStream(stream) === best);
|
||||
|
||||
if (preferredQuality !== undefined && preferredQuality.length > 0) {
|
||||
const needle = preferredQuality.toLowerCase();
|
||||
const match = candidates.find((stream) => stream.quality.toLowerCase().includes(needle));
|
||||
if (match) return match;
|
||||
}
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { interleave, mapSourcesConcurrently } from './multi-source-search';
|
||||
|
||||
const source = (id: string) => ({ id, name: `Source ${id}` });
|
||||
|
||||
test('mapSourcesConcurrently returns results in source order, not completion order', async () => {
|
||||
const sources = [source('a'), source('b'), source('c')];
|
||||
const delays: Record<string, number> = { a: 20, b: 0, c: 10 };
|
||||
|
||||
const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delays[target.id]));
|
||||
return target.id;
|
||||
});
|
||||
|
||||
assert.deepEqual(results, ['a', 'b', 'c']);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('a failing source is reported without losing the others', async () => {
|
||||
const sources = [source('a'), source('b'), source('c')];
|
||||
|
||||
const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
|
||||
if (target.id === 'b') throw new Error('login required');
|
||||
return target.id;
|
||||
});
|
||||
|
||||
assert.deepEqual(results, ['a', 'c']);
|
||||
assert.deepEqual(failures, [{ sourceId: 'b', sourceName: 'Source b', error: 'login required' }]);
|
||||
});
|
||||
|
||||
test('mapSourcesConcurrently never runs more than the concurrency limit at once', async () => {
|
||||
const sources = ['a', 'b', 'c', 'd', 'e'].map(source);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
|
||||
await mapSourcesConcurrently(
|
||||
sources,
|
||||
async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
running -= 1;
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
assert.equal(peak, 2);
|
||||
});
|
||||
|
||||
test('mapSourcesConcurrently handles an empty source list', async () => {
|
||||
const { results, failures } = await mapSourcesConcurrently([], async () => 'x');
|
||||
assert.deepEqual(results, []);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('interleave takes one from each source before taking a second', () => {
|
||||
assert.deepEqual(interleave([['a1', 'a2', 'a3'], ['b1'], ['c1', 'c2']]), [
|
||||
'a1',
|
||||
'b1',
|
||||
'c1',
|
||||
'a2',
|
||||
'c2',
|
||||
'a3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('interleave ignores empty groups', () => {
|
||||
assert.deepEqual(interleave([[], ['b1', 'b2'], []]), ['b1', 'b2']);
|
||||
assert.deepEqual(interleave([]), []);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { SourceSearchFailure } from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* Running one query against every installed source at once.
|
||||
*
|
||||
* Each source is a separate extension behind the same single-threaded bridge,
|
||||
* so the fan-out is bounded rather than unleashed: a dozen extensions all
|
||||
* uploading and searching at once starves the ones the user is waiting on.
|
||||
*/
|
||||
|
||||
/** Enough to hide the latency of a slow source without queueing the bridge. */
|
||||
const DEFAULT_CONCURRENCY = 4;
|
||||
|
||||
export interface SourceTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FanOutResult<T> {
|
||||
/** One entry per source that succeeded, in source order. */
|
||||
results: T[];
|
||||
/** One entry per source that threw, in source order. */
|
||||
failures: SourceSearchFailure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` against every source, at most `concurrency` at a time.
|
||||
*
|
||||
* A source that throws becomes a failure instead of rejecting the whole call —
|
||||
* one misconfigured extension must not hide every other source's results.
|
||||
*/
|
||||
export async function mapSourcesConcurrently<S extends SourceTarget, T>(
|
||||
sources: S[],
|
||||
task: (source: S) => Promise<T>,
|
||||
concurrency: number = DEFAULT_CONCURRENCY,
|
||||
): Promise<FanOutResult<T>> {
|
||||
// Slots keep the output in source order regardless of completion order, so
|
||||
// the same query lays out the same way twice.
|
||||
const results: Array<{ value: T } | null> = sources.map(() => null);
|
||||
const failures: Array<SourceSearchFailure | null> = sources.map(() => null);
|
||||
let next = 0;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const source = sources[index];
|
||||
if (!source) return;
|
||||
try {
|
||||
results[index] = { value: await task(source) };
|
||||
} catch (error) {
|
||||
failures[index] = {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Math.max(1, Math.min(concurrency, sources.length));
|
||||
await Promise.all(Array.from({ length: workers }, () => worker()));
|
||||
|
||||
return {
|
||||
results: results
|
||||
.filter((slot): slot is { value: T } => slot !== null)
|
||||
.map((slot) => slot.value),
|
||||
failures: failures.filter((slot): slot is SourceSearchFailure => slot !== null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-robin merge, so the grid opens with one hit from each source rather
|
||||
* than the whole of the first source before the second one starts.
|
||||
*/
|
||||
export function interleave<T>(groups: T[][]): T[] {
|
||||
const merged: T[] = [];
|
||||
const longest = groups.reduce((max, group) => Math.max(max, group.length), 0);
|
||||
for (let index = 0; index < longest; index += 1) {
|
||||
for (const group of groups) {
|
||||
if (index < group.length) merged.push(group[index] as T);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { watchPlaybackOutcome, type PlaybackEndFileEvent } from './playback-outcome';
|
||||
|
||||
type Harness = {
|
||||
emitEndFile: (event: PlaybackEndFileEvent) => void;
|
||||
listenerCount: () => number;
|
||||
setProperty: (name: string, value: unknown) => void;
|
||||
failProperty: (name: string) => void;
|
||||
/** Virtual milliseconds burned so far, by sleeps and by property reads. */
|
||||
elapsed: () => number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The clock is virtual and only moves when the code under test sleeps (or,
|
||||
* with `readCostMs`, when it reads a property), so timeout behaviour is
|
||||
* asserted without any real waiting.
|
||||
*/
|
||||
function createHarness(overrides?: {
|
||||
timeoutMs?: number;
|
||||
probeIntervalMs?: number;
|
||||
readCostMs?: number;
|
||||
}) {
|
||||
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
|
||||
const properties = new Map<string, unknown>();
|
||||
const failing = new Set<string>();
|
||||
const readCostMs = overrides?.readCostMs ?? 0;
|
||||
let clock = 0;
|
||||
|
||||
const watch = watchPlaybackOutcome({
|
||||
onEndFile: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
readProperty: async (name) => {
|
||||
clock += readCostMs;
|
||||
if (failing.has(name)) throw new Error(`Failed to read MPV property '${name}'`);
|
||||
return properties.get(name);
|
||||
},
|
||||
wait: async (ms) => {
|
||||
clock += ms;
|
||||
},
|
||||
now: () => clock,
|
||||
timeoutMs: overrides?.timeoutMs ?? 1000,
|
||||
probeIntervalMs: overrides?.probeIntervalMs ?? 100,
|
||||
});
|
||||
|
||||
const harness: Harness = {
|
||||
emitEndFile: (event) => {
|
||||
for (const listener of listeners) listener(event);
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
setProperty: (name, value) => properties.set(name, value),
|
||||
failProperty: (name) => failing.add(name),
|
||||
elapsed: () => clock,
|
||||
};
|
||||
return { watch, harness };
|
||||
}
|
||||
|
||||
test('resolves ok once mpv configures a video output', async () => {
|
||||
const { watch, harness } = createHarness();
|
||||
harness.setProperty('vo-configured', true);
|
||||
const outcome = await watch.wait();
|
||||
assert.deepEqual(outcome, { ok: true });
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('resolves failure with the mpv error when the file ends in error', async () => {
|
||||
const { watch, harness } = createHarness();
|
||||
const pending = watch.wait();
|
||||
harness.emitEndFile({ reason: 'error', fileError: 'no audio or video data played' });
|
||||
const outcome = await pending;
|
||||
assert.equal(outcome.ok, false);
|
||||
assert.ok(!outcome.ok && outcome.error.includes('no audio or video data played'));
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('ignores the end-file fired for the file being replaced', async () => {
|
||||
const { watch, harness } = createHarness();
|
||||
const pending = watch.wait();
|
||||
harness.emitEndFile({ reason: 'stop', fileError: null });
|
||||
harness.setProperty('vo-configured', true);
|
||||
const outcome = await pending;
|
||||
assert.deepEqual(outcome, { ok: true });
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('times out with a failure when nothing ever starts', async () => {
|
||||
const { watch, harness } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 });
|
||||
const outcome = await watch.wait();
|
||||
assert.equal(outcome.ok, false);
|
||||
assert.ok(!outcome.ok && outcome.error.length > 0);
|
||||
assert.equal(harness.elapsed(), 300);
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('slow property reads eat the budget instead of extending it', async () => {
|
||||
const { watch, harness } = createHarness({
|
||||
timeoutMs: 300,
|
||||
probeIntervalMs: 100,
|
||||
readCostMs: 250,
|
||||
});
|
||||
const outcome = await watch.wait();
|
||||
assert.equal(outcome.ok, false);
|
||||
// Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again.
|
||||
assert.ok(harness.elapsed() >= 300, 'gave up before the timeout');
|
||||
assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout');
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('a zero probe interval still terminates at the deadline', async () => {
|
||||
const { watch } = createHarness({ timeoutMs: 200, probeIntervalMs: 0, readCostMs: 50 });
|
||||
const outcome = await watch.wait();
|
||||
assert.equal(outcome.ok, false);
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('keeps polling through property read failures', async () => {
|
||||
const { watch, harness } = createHarness();
|
||||
harness.failProperty('vo-configured');
|
||||
const pending = watch.wait();
|
||||
harness.emitEndFile({ reason: 'error', fileError: null });
|
||||
const outcome = await pending;
|
||||
assert.equal(outcome.ok, false);
|
||||
watch.dispose();
|
||||
});
|
||||
|
||||
test('dispose removes the end-file subscription', () => {
|
||||
const { watch, harness } = createHarness();
|
||||
assert.equal(harness.listenerCount(), 1);
|
||||
watch.dispose();
|
||||
assert.equal(harness.listenerCount(), 0);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Confirms that a `loadfile` handed to mpv actually turned into playback.
|
||||
*
|
||||
* Sending the command proves nothing: mpv accepts the file, fails to decode it
|
||||
* (a dead host, a disguised stream the proxy could not fix), fires `end-file`
|
||||
* with reason "error", and drops back to `--idle` — with no window, because
|
||||
* idle mpv shows none. The UI would happily say "Playing" over a blank desktop.
|
||||
*
|
||||
* Success is mpv configuring a video output (`vo-configured`), which is
|
||||
* literally "a window with frames in it". `file-loaded` is not enough — the
|
||||
* broken stream in the wild reached it before dying. Failure is an `end-file`
|
||||
* with reason "error"; the end-file of the file being *replaced* arrives with
|
||||
* "stop"/"redirect" and is ignored.
|
||||
*/
|
||||
|
||||
export interface PlaybackEndFileEvent {
|
||||
reason: string;
|
||||
fileError: string | null;
|
||||
}
|
||||
|
||||
export type PlaybackOutcome = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface WatchPlaybackOutcomeDeps {
|
||||
/** Subscribe to mpv end-file events; returns the unsubscribe. */
|
||||
onEndFile: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
|
||||
/** One-shot mpv property read; may reject while the file is still loading. */
|
||||
readProperty: (name: string) => Promise<unknown>;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
/** Injectable clock; the timeout is wall-clock, not a probe count. */
|
||||
now?: () => number;
|
||||
timeoutMs?: number;
|
||||
probeIntervalMs?: number;
|
||||
}
|
||||
|
||||
export interface PlaybackOutcomeWatch {
|
||||
wait: () => Promise<PlaybackOutcome>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_PROBE_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* Call *before* sending `loadfile` so the error subscription cannot lose a
|
||||
* race against a fast failure; await `wait()` after the commands went out.
|
||||
*/
|
||||
export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOutcomeWatch {
|
||||
const timeoutMs = deps.timeoutMs ?? DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS;
|
||||
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
|
||||
|
||||
let failure: PlaybackOutcome | null = null;
|
||||
const unsubscribe = deps.onEndFile((event) => {
|
||||
if (event.reason !== 'error') return;
|
||||
failure = {
|
||||
ok: false,
|
||||
error: event.fileError
|
||||
? `mpv could not play this stream: ${event.fileError}`
|
||||
: 'mpv could not play this stream.',
|
||||
};
|
||||
});
|
||||
|
||||
async function wait(): Promise<PlaybackOutcome> {
|
||||
// Wall-clock, not a probe count: a slow `readProperty` must eat into the
|
||||
// budget rather than stretch it, and a zero probe interval must still end.
|
||||
const now = deps.now ?? Date.now;
|
||||
const deadline = now() + timeoutMs;
|
||||
while (now() < deadline) {
|
||||
if (failure) return failure;
|
||||
try {
|
||||
if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
|
||||
} catch {
|
||||
// The property is unreadable while mpv is between files; keep polling.
|
||||
}
|
||||
if (failure) return failure;
|
||||
// Sleeping past the deadline would only delay the timeout report.
|
||||
const remaining = deadline - now();
|
||||
if (remaining <= 0) break;
|
||||
await deps.wait(Math.min(probeIntervalMs, remaining));
|
||||
}
|
||||
return (
|
||||
failure ?? {
|
||||
ok: false,
|
||||
error: 'Playback did not start. mpv gave no error; try another server or quality.',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { wait, dispose: unsubscribe };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PreferenceStore } from './preference-store';
|
||||
|
||||
async function storeFile(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-prefs-'));
|
||||
return path.join(dir, 'anime-preferences.json');
|
||||
}
|
||||
|
||||
test('values round-trip through the file', async () => {
|
||||
const file = await storeFile();
|
||||
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'address' }]);
|
||||
|
||||
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), [{ key: 'address' }]);
|
||||
});
|
||||
|
||||
test('the same bridge source id is isolated between extension packages', async () => {
|
||||
const file = await storeFile();
|
||||
const store = new PreferenceStore(file);
|
||||
|
||||
await store.set('pkg.one', 'shared-source', [{ key: 'password', value: 'one-secret' }]);
|
||||
await store.set('pkg.two', 'shared-source', [{ key: 'password', value: 'two-secret' }]);
|
||||
|
||||
const reloaded = new PreferenceStore(file);
|
||||
assert.deepEqual(await reloaded.get('pkg.one', 'shared-source'), [
|
||||
{ key: 'password', value: 'one-secret' },
|
||||
]);
|
||||
assert.deepEqual(await reloaded.get('pkg.two', 'shared-source'), [
|
||||
{ key: 'password', value: 'two-secret' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a legacy bare source id is discarded rather than assigned to an unproven package', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(file, JSON.stringify({ 'legacy-source': [{ key: 'address', value: 'saved' }] }));
|
||||
|
||||
const store = new PreferenceStore(file);
|
||||
assert.deepEqual(await store.get('pkg.one', 'legacy-source'), []);
|
||||
|
||||
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
|
||||
assert.equal(persisted['legacy-source'], undefined);
|
||||
assert.equal(persisted['pkg.one:legacy-source'], undefined);
|
||||
});
|
||||
|
||||
test('an ambiguous legacy source id is discarded instead of exposed to either package', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(file, JSON.stringify({ shared: [{ key: 'password', value: 'old-secret' }] }));
|
||||
|
||||
const store = new PreferenceStore(file);
|
||||
assert.deepEqual(await store.get('pkg.one', 'shared'), []);
|
||||
assert.deepEqual(await store.get('pkg.two', 'shared'), []);
|
||||
|
||||
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
|
||||
assert.equal(persisted.shared, undefined);
|
||||
});
|
||||
|
||||
test('concurrent writes on a cold cache do not lose an update', async () => {
|
||||
const file = await storeFile();
|
||||
const store = new PreferenceStore(file);
|
||||
|
||||
// Both start before either has loaded; unserialized they would each get their
|
||||
// own object and the later persist would drop the other's entry.
|
||||
await Promise.all([
|
||||
store.set('pkg', 'src-1', [{ key: 'a' }]),
|
||||
store.set('pkg', 'src-2', [{ key: 'b' }]),
|
||||
]);
|
||||
|
||||
const reloaded = new PreferenceStore(file);
|
||||
assert.deepEqual(await reloaded.get('pkg', 'src-1'), [{ key: 'a' }]);
|
||||
assert.deepEqual(await reloaded.get('pkg', 'src-2'), [{ key: 'b' }]);
|
||||
});
|
||||
|
||||
test('a clear racing a set is applied in order', async () => {
|
||||
const file = await storeFile();
|
||||
const store = new PreferenceStore(file);
|
||||
await store.set('pkg', 'src', [{ key: 'password' }]);
|
||||
|
||||
await Promise.all([store.clear('pkg'), store.set('other', 'src', [{ key: 'x' }])]);
|
||||
|
||||
const reloaded = new PreferenceStore(file);
|
||||
assert.deepEqual(await reloaded.get('pkg', 'src'), []);
|
||||
assert.deepEqual(await reloaded.get('other', 'src'), [{ key: 'x' }]);
|
||||
});
|
||||
|
||||
test('the file is written owner-only even when an existing temporary file is permissive', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(`${file}.tmp`, 'stale');
|
||||
await chmod(`${file}.tmp`, 0o666);
|
||||
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'password' }]);
|
||||
|
||||
assert.equal((await stat(file)).mode & 0o777, 0o600);
|
||||
assert.deepEqual(await readdir(path.dirname(file)), [path.basename(file)]);
|
||||
});
|
||||
|
||||
test('a corrupt file starts empty rather than blocking the browser', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(file, '{ not json');
|
||||
|
||||
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), []);
|
||||
});
|
||||
|
||||
test('malformed persisted values are filtered to preference objects with string keys', async () => {
|
||||
const file = await storeFile();
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
'pkg:source': [null, { key: 42 }, 'bad', { key: 'valid', value: 'kept' }],
|
||||
'pkg:not-an-array': { key: 'invalid-container' },
|
||||
}),
|
||||
);
|
||||
|
||||
const store = new PreferenceStore(file);
|
||||
assert.deepEqual(await store.get('pkg', 'source'), [{ key: 'valid', value: 'kept' }]);
|
||||
assert.deepEqual(await store.get('pkg', 'not-an-array'), []);
|
||||
});
|
||||
|
||||
test('a write replaces the previous contents wholesale', async () => {
|
||||
const file = await storeFile();
|
||||
const store = new PreferenceStore(file);
|
||||
await store.set('pkg', 'src-1', [{ key: 'first' }]);
|
||||
await store.set('pkg', 'src-1', [{ key: 'second' }]);
|
||||
|
||||
const parsed = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown[]>;
|
||||
assert.deepEqual(parsed['pkg:src-1'], [{ key: 'second' }]);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { chmod, readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
function parseStoredPreferences(value: unknown): Record<string, BridgePreference[]> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
|
||||
const parsed: Record<string, BridgePreference[]> = {};
|
||||
for (const [key, entries] of Object.entries(value)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
parsed[key] = entries.filter(
|
||||
(entry): entry is BridgePreference =>
|
||||
entry !== null &&
|
||||
typeof entry === 'object' &&
|
||||
!Array.isArray(entry) &&
|
||||
typeof (entry as Record<string, unknown>).key === 'string',
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists each source's preference array verbatim, keyed by extension package
|
||||
* and bridge source id.
|
||||
*
|
||||
* Extensions keep credentials in here (the Jellyfin source stores a password),
|
||||
* so the file is written with owner-only permissions.
|
||||
*/
|
||||
export class PreferenceStore {
|
||||
private readonly file: string;
|
||||
private cache: Record<string, BridgePreference[]> | null = null;
|
||||
/**
|
||||
* Mutations run one at a time. Two concurrent load-modify-persist cycles
|
||||
* starting on a cold cache would each read their own object, and the later
|
||||
* write would drop the earlier one's edit.
|
||||
*/
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(file: string) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.queue.then(operation, operation);
|
||||
// Keep the chain alive after a rejection so one failure cannot wedge it.
|
||||
this.queue = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async load(): Promise<Record<string, BridgePreference[]>> {
|
||||
if (this.cache !== null) return this.cache;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown;
|
||||
this.cache = parseStoredPreferences(parsed);
|
||||
} catch {
|
||||
// Missing or corrupt file starts empty rather than blocking the browser.
|
||||
this.cache = {};
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
async get(pkg: string, sourceId: string): Promise<BridgePreference[]> {
|
||||
return this.enqueue(async () => {
|
||||
const all = await this.load();
|
||||
const key = `${pkg}:${sourceId}`;
|
||||
if (all[key]) return all[key];
|
||||
|
||||
// Bare source IDs predate package scoping and have no trustworthy owner.
|
||||
// Never assign their credentials to whichever package happens to ask first.
|
||||
const legacy = all[sourceId];
|
||||
if (legacy) {
|
||||
delete all[sourceId];
|
||||
await this.persist(all);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
async set(pkg: string, sourceId: string, preferences: BridgePreference[]): Promise<void> {
|
||||
await this.enqueue(async () => {
|
||||
const all = await this.load();
|
||||
all[`${pkg}:${sourceId}`] = preferences;
|
||||
await this.persist(all);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every saved value whose key starts with `prefix`.
|
||||
*
|
||||
* Removing an extension should not leave its credentials on disk, and a
|
||||
* source id is not knowable once the APK is gone — so callers pass the
|
||||
* package name and this clears anything recorded under it.
|
||||
*/
|
||||
async clear(prefix: string): Promise<void> {
|
||||
await this.enqueue(async () => {
|
||||
const all = await this.load();
|
||||
let changed = false;
|
||||
for (const key of Object.keys(all)) {
|
||||
if (key === prefix || key.startsWith(`${prefix}:`)) {
|
||||
delete all[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await this.persist(all);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write through a temporary file and rename into place.
|
||||
*
|
||||
* A write interrupted partway would otherwise leave truncated JSON, and
|
||||
* `load()` treats unparseable content as empty — which would quietly discard
|
||||
* every saved credential.
|
||||
*/
|
||||
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
|
||||
await mkdir(path.dirname(this.file), { recursive: true });
|
||||
const temporary = `${this.file}.tmp`;
|
||||
try {
|
||||
await chmod(temporary, 0o600).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
|
||||
await chmod(temporary, 0o600);
|
||||
await rename(temporary, this.file);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyPreferenceValue,
|
||||
isSecretPreference,
|
||||
parsePreferences,
|
||||
type SourcePreferenceView,
|
||||
} from './preferences';
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
// Shapes taken from the real Jellyfin extension's preferencesAnime response.
|
||||
const RAW: BridgePreference[] = [
|
||||
{
|
||||
key: 'host_url',
|
||||
editTextPreference: {
|
||||
title: 'Address',
|
||||
summary: 'The server address',
|
||||
value: '',
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
editTextPreference: { title: 'Password', summary: 'The user account password', value: '' },
|
||||
},
|
||||
{
|
||||
key: 'pref_quality',
|
||||
listPreference: {
|
||||
title: 'Preferred quality',
|
||||
summary: 'Preferred quality.',
|
||||
valueIndex: 0,
|
||||
entries: ['Source', '20 Mbps'],
|
||||
entryValues: ['source', '20000000'],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pref_episode_details_key',
|
||||
multiSelectListPreference: {
|
||||
title: 'Additional details for episodes',
|
||||
values: [],
|
||||
entries: ['Overview', 'Runtime'],
|
||||
entryValues: ['overview', 'runtime'],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pref_trust_cert',
|
||||
switchPreferenceCompat: { title: 'Trust certificate', value: false },
|
||||
},
|
||||
];
|
||||
|
||||
function view(views: SourcePreferenceView[], key: string): SourcePreferenceView {
|
||||
const found = views.find((candidate) => candidate.key === key);
|
||||
assert.ok(found, `missing preference ${key}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
test('parsePreferences flattens each widget type', () => {
|
||||
const views = parsePreferences(RAW);
|
||||
assert.equal(views.length, 5);
|
||||
|
||||
assert.equal(view(views, 'host_url').kind, 'text');
|
||||
assert.equal(view(views, 'host_url').title, 'Address');
|
||||
assert.equal(view(views, 'host_url').value, '');
|
||||
|
||||
const quality = view(views, 'pref_quality');
|
||||
assert.equal(quality.kind, 'list');
|
||||
// valueIndex 0 resolves through entryValues, not entries.
|
||||
assert.equal(quality.value, 'source');
|
||||
assert.deepEqual(quality.entries, ['Source', '20 Mbps']);
|
||||
|
||||
assert.deepEqual(view(views, 'pref_episode_details_key').value, []);
|
||||
assert.equal(view(views, 'pref_trust_cert').value, false);
|
||||
});
|
||||
|
||||
test('parsePreferences skips the bridge context entry and unknown widgets', () => {
|
||||
const views = parsePreferences([
|
||||
{ key: '__mangatan_bridge_context__', sourceId: '1' },
|
||||
{ key: 'mystery', someFutureWidget: { title: 'X' } },
|
||||
...RAW.slice(0, 1),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
views.map((v) => v.key),
|
||||
['host_url'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a list preference with no selection reads as empty', () => {
|
||||
const views = parsePreferences([
|
||||
{
|
||||
key: 'library_pref',
|
||||
listPreference: {
|
||||
title: 'Select media library',
|
||||
valueIndex: -1,
|
||||
entries: [],
|
||||
entryValues: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(view(views, 'library_pref').value, '');
|
||||
});
|
||||
|
||||
test('applyPreferenceValue writes text into both value and text', () => {
|
||||
const updated = applyPreferenceValue(RAW, 'host_url', 'https://media.example');
|
||||
const body = updated.find((e) => e.key === 'host_url')!.editTextPreference as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(body.value, 'https://media.example');
|
||||
assert.equal(body.text, 'https://media.example');
|
||||
// Other entries are untouched.
|
||||
assert.equal(parsePreferences(updated).length, RAW.length);
|
||||
});
|
||||
|
||||
test('applyPreferenceValue moves a list preference by entry value', () => {
|
||||
const updated = applyPreferenceValue(RAW, 'pref_quality', '20000000');
|
||||
const body = updated.find((e) => e.key === 'pref_quality')!.listPreference as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(body.valueIndex, 1);
|
||||
assert.equal(parsePreferences(updated).find((v) => v.key === 'pref_quality')?.value, '20000000');
|
||||
});
|
||||
|
||||
test('applyPreferenceValue handles multi-select and switch widgets', () => {
|
||||
const multi = applyPreferenceValue(RAW, 'pref_episode_details_key', ['overview']);
|
||||
assert.deepEqual(
|
||||
parsePreferences(multi).find((v) => v.key === 'pref_episode_details_key')?.value,
|
||||
['overview'],
|
||||
);
|
||||
|
||||
const toggled = applyPreferenceValue(RAW, 'pref_trust_cert', true);
|
||||
assert.equal(parsePreferences(toggled).find((v) => v.key === 'pref_trust_cert')?.value, true);
|
||||
});
|
||||
|
||||
test('applyPreferenceValue leaves unknown keys alone', () => {
|
||||
assert.deepEqual(applyPreferenceValue(RAW, 'not-a-key', 'x'), RAW);
|
||||
});
|
||||
|
||||
test('secrets are recognised by key or title', () => {
|
||||
const views = parsePreferences(RAW);
|
||||
assert.equal(isSecretPreference(view(views, 'password')), true);
|
||||
assert.equal(isSecretPreference(view(views, 'host_url')), false);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
/**
|
||||
* Extension preferences arrive as Android preference objects, one wrapper key
|
||||
* per widget type. They are stored and sent back verbatim so the extension sees
|
||||
* exactly the shape it produced; only the value field is edited.
|
||||
*/
|
||||
|
||||
export type PreferenceKind = 'text' | 'list' | 'multi' | 'switch';
|
||||
|
||||
/** A preference flattened for rendering. */
|
||||
export interface SourcePreferenceView {
|
||||
key: string;
|
||||
kind: PreferenceKind;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
/** Current value: string for text/list, string[] for multi, boolean for switch. */
|
||||
value: string | string[] | boolean;
|
||||
/** Display labels, parallel to entryValues. Empty for text/switch. */
|
||||
entries: string[];
|
||||
entryValues: string[];
|
||||
}
|
||||
|
||||
const WIDGETS = {
|
||||
editTextPreference: 'text',
|
||||
listPreference: 'list',
|
||||
multiSelectListPreference: 'multi',
|
||||
switchPreferenceCompat: 'switch',
|
||||
checkBoxPreference: 'switch',
|
||||
} as const satisfies Record<string, PreferenceKind>;
|
||||
|
||||
type WidgetName = keyof typeof WIDGETS;
|
||||
|
||||
function widgetOf(
|
||||
entry: BridgePreference,
|
||||
): { name: WidgetName; body: Record<string, unknown> } | null {
|
||||
for (const name of Object.keys(WIDGETS) as WidgetName[]) {
|
||||
const body = entry[name];
|
||||
if (body !== null && typeof body === 'object') {
|
||||
return { name, body: body as Record<string, unknown> };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Flatten bridge preference entries for display. Unknown widgets are skipped. */
|
||||
export function parsePreferences(raw: BridgePreference[]): SourcePreferenceView[] {
|
||||
const views: SourcePreferenceView[] = [];
|
||||
|
||||
for (const entry of raw) {
|
||||
if (typeof entry.key !== 'string' || entry.key.startsWith('__')) continue;
|
||||
const widget = widgetOf(entry);
|
||||
if (!widget) continue;
|
||||
|
||||
const { body } = widget;
|
||||
const kind = WIDGETS[widget.name];
|
||||
const entries = stringList(body.entries);
|
||||
const entryValues = stringList(body.entryValues);
|
||||
|
||||
let value: string | string[] | boolean;
|
||||
if (kind === 'multi') {
|
||||
value = stringList(body.values);
|
||||
} else if (kind === 'switch') {
|
||||
value = body.value === true;
|
||||
} else if (kind === 'list') {
|
||||
const index = typeof body.valueIndex === 'number' ? body.valueIndex : -1;
|
||||
// valueIndex is -1 when the extension has no selection yet.
|
||||
value = index >= 0 && index < entryValues.length ? entryValues[index]! : '';
|
||||
} else {
|
||||
value = typeof body.value === 'string' ? body.value : '';
|
||||
}
|
||||
|
||||
views.push({
|
||||
key: entry.key,
|
||||
kind,
|
||||
title: typeof body.title === 'string' ? body.title : entry.key,
|
||||
summary: typeof body.summary === 'string' ? body.summary : null,
|
||||
value,
|
||||
entries,
|
||||
entryValues,
|
||||
});
|
||||
}
|
||||
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of `raw` with one preference's value replaced, in whichever
|
||||
* fields that widget type reads. Unknown keys are returned unchanged.
|
||||
*/
|
||||
export function applyPreferenceValue(
|
||||
raw: BridgePreference[],
|
||||
key: string,
|
||||
value: string | string[] | boolean,
|
||||
): BridgePreference[] {
|
||||
return raw.map((entry) => {
|
||||
if (entry.key !== key) return entry;
|
||||
const widget = widgetOf(entry);
|
||||
if (!widget) return entry;
|
||||
|
||||
const body = { ...widget.body };
|
||||
const kind = WIDGETS[widget.name];
|
||||
|
||||
if (kind === 'multi') {
|
||||
body.values = Array.isArray(value) ? value : [];
|
||||
} else if (kind === 'switch') {
|
||||
body.value = value === true;
|
||||
} else if (kind === 'list') {
|
||||
const entryValues = stringList(body.entryValues);
|
||||
const index = entryValues.indexOf(String(value));
|
||||
body.valueIndex = index;
|
||||
if (index >= 0) body.value = entryValues[index];
|
||||
} else {
|
||||
// editTextPreference carries the same string in both value and text.
|
||||
body.value = String(value);
|
||||
body.text = String(value);
|
||||
}
|
||||
|
||||
return { ...entry, [widget.name]: body };
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the preference should be masked in the UI and in logs. */
|
||||
export function isSecretPreference(view: SourcePreferenceView): boolean {
|
||||
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
bundleReleaseUrl,
|
||||
findBundleBinaries,
|
||||
PINNED_BUNDLE_SHA256,
|
||||
PINNED_BUNDLE_TAG,
|
||||
resolveBundleAssetName,
|
||||
selectBundleAsset,
|
||||
sha256,
|
||||
verifyPinnedBundle,
|
||||
} from './sidecar-bundle';
|
||||
|
||||
test('resolveBundleAssetName maps supported platform/arch pairs', () => {
|
||||
assert.equal(resolveBundleAssetName('darwin', 'arm64'), 'macOS-arm64-bundle.zip');
|
||||
assert.equal(resolveBundleAssetName('darwin', 'x64'), 'macOS-x64-bundle.zip');
|
||||
assert.equal(resolveBundleAssetName('linux', 'x64'), 'linux-x64-bundle.zip');
|
||||
assert.equal(resolveBundleAssetName('win32', 'x64'), 'windows-x64-bundle.zip');
|
||||
});
|
||||
|
||||
test('resolveBundleAssetName returns null for unpublished combinations', () => {
|
||||
assert.equal(resolveBundleAssetName('linux', 'arm64'), null);
|
||||
assert.equal(resolveBundleAssetName('win32', 'arm64'), null);
|
||||
assert.equal(resolveBundleAssetName('freebsd', 'x64'), null);
|
||||
});
|
||||
|
||||
const PINNED_RELEASE = {
|
||||
tag_name: PINNED_BUNDLE_TAG,
|
||||
assets: [
|
||||
{
|
||||
name: 'macOS-arm64-bundle.zip',
|
||||
browser_download_url: 'https://example.test/macOS-arm64-bundle.zip',
|
||||
size: 133_058_560,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('selectBundleAsset reads the by-tag endpoint payload', () => {
|
||||
const asset = selectBundleAsset(PINNED_RELEASE, 'macOS-arm64-bundle.zip');
|
||||
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
|
||||
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
|
||||
assert.equal(asset?.sizeBytes, 133_058_560);
|
||||
});
|
||||
|
||||
test('selectBundleAsset skips releases without a matching asset', () => {
|
||||
const releases = [
|
||||
// The iOS runtime release carries no desktop bundle.
|
||||
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
|
||||
PINNED_RELEASE,
|
||||
];
|
||||
|
||||
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
|
||||
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
|
||||
});
|
||||
|
||||
test('selectBundleAsset ignores releases newer than the pin', () => {
|
||||
const releases = [
|
||||
{
|
||||
tag_name: 'v9.9.9.9',
|
||||
assets: [
|
||||
{
|
||||
name: 'macOS-arm64-bundle.zip',
|
||||
browser_download_url: 'https://example.test/unpinned.zip',
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
PINNED_RELEASE,
|
||||
];
|
||||
|
||||
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
|
||||
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
|
||||
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
|
||||
});
|
||||
|
||||
test('selectBundleAsset returns null when nothing matches', () => {
|
||||
assert.equal(
|
||||
selectBundleAsset([{ tag_name: PINNED_BUNDLE_TAG, assets: [] }], 'linux-x64-bundle.zip'),
|
||||
null,
|
||||
);
|
||||
assert.equal(selectBundleAsset([], 'linux-x64-bundle.zip'), null);
|
||||
assert.equal(selectBundleAsset({ message: 'rate limited' }, 'linux-x64-bundle.zip'), null);
|
||||
});
|
||||
|
||||
test('bundleReleaseUrl targets the pinned tag', () => {
|
||||
assert.equal(
|
||||
bundleReleaseUrl(),
|
||||
`https://api.github.com/repos/1Selxo/M-Extension-Server/releases/tags/${PINNED_BUNDLE_TAG}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('findBundleBinaries locates the nested jre and jar', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
|
||||
await mkdir(path.join(root, 'jre', 'jre', 'bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'jre', 'jre', 'bin', 'java'), '');
|
||||
await writeFile(path.join(root, 'MExtensionServer-1.0.6.0.jar'), '');
|
||||
|
||||
const binaries = await findBundleBinaries(root);
|
||||
assert.equal(binaries?.javaPath, path.join(root, 'jre', 'jre', 'bin', 'java'));
|
||||
assert.equal(binaries?.jarPath, path.join(root, 'MExtensionServer-1.0.6.0.jar'));
|
||||
});
|
||||
|
||||
test('findBundleBinaries prefers the shallowest java when a nested copy exists', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
|
||||
await mkdir(path.join(root, 'bin'), { recursive: true });
|
||||
await mkdir(path.join(root, 'bin', 'nested', 'bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'bin', 'java'), '');
|
||||
await writeFile(path.join(root, 'bin', 'nested', 'bin', 'java'), '');
|
||||
await writeFile(path.join(root, 'MExtensionServer.jar'), '');
|
||||
|
||||
const binaries = await findBundleBinaries(root);
|
||||
assert.equal(binaries?.javaPath, path.join(root, 'bin', 'java'));
|
||||
});
|
||||
|
||||
test('findBundleBinaries returns null when the bundle is incomplete', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
|
||||
await writeFile(path.join(root, 'MExtensionServer.jar'), '');
|
||||
assert.equal(await findBundleBinaries(root), null);
|
||||
});
|
||||
|
||||
test('sha256 produces lowercase hex digests matching known vectors', () => {
|
||||
const encode = (value: string) => new TextEncoder().encode(value);
|
||||
assert.equal(
|
||||
sha256(encode('')),
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
);
|
||||
assert.equal(
|
||||
sha256(encode('subminer')),
|
||||
'f3b7fdb2037add4cd8f122c090a727243b46b1b9d8a6c379f71573e2df120885',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyPinnedBundle accepts a matching hash and rejects a mismatch', () => {
|
||||
const asset = 'macOS-arm64-bundle.zip';
|
||||
const wrong = verifyPinnedBundle(asset, new TextEncoder().encode('not the bundle'));
|
||||
assert.equal(wrong.ok, false);
|
||||
assert.match((wrong as { reason: string }).reason, /Checksum mismatch/);
|
||||
});
|
||||
|
||||
test('verifyPinnedBundle refuses an asset that has no pin', () => {
|
||||
const result = verifyPinnedBundle('windows-x64-bundle.zip', new Uint8Array([1, 2, 3]));
|
||||
assert.equal(result.ok, false);
|
||||
assert.match((result as { reason: string }).reason, /No pinned checksum/);
|
||||
});
|
||||
|
||||
test('the pinned tag and hashes are the verified release', () => {
|
||||
assert.equal(PINNED_BUNDLE_TAG, 'v1.0.6.0');
|
||||
assert.equal(
|
||||
PINNED_BUNDLE_SHA256['macOS-arm64-bundle.zip'],
|
||||
'5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
|
||||
);
|
||||
assert.equal(
|
||||
PINNED_BUNDLE_SHA256['linux-x64-bundle.zip'],
|
||||
'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Locates the M-Extension-Server release bundle for the host platform. Each
|
||||
* bundle ships a matching JRE alongside the server JAR, so no system JDK is
|
||||
* required.
|
||||
*/
|
||||
|
||||
const BUNDLE_REPO_API = 'https://api.github.com/repos/1Selxo/M-Extension-Server';
|
||||
|
||||
/**
|
||||
* Fetch the pinned release by tag rather than listing releases: upstream ships
|
||||
* several a week, so a paged list would scroll the pinned tag off page one.
|
||||
*/
|
||||
export function bundleReleaseUrl(tagName: string = PINNED_BUNDLE_TAG): string {
|
||||
return `${BUNDLE_REPO_API}/releases/tags/${encodeURIComponent(tagName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge release this integration was verified against.
|
||||
*
|
||||
* Upstream publishes no checksums for the desktop bundles, so we pin a tag and
|
||||
* a hash we computed ourselves rather than tracking "latest". Bumping this
|
||||
* means downloading the new asset, verifying it starts and reports the
|
||||
* capabilities in `AnimeBridgeClient.isReady`, then updating both fields.
|
||||
*/
|
||||
export const PINNED_BUNDLE_TAG = 'v1.0.6.0';
|
||||
|
||||
/** SHA-256 of each pinned asset, keyed by release asset name. */
|
||||
export const PINNED_BUNDLE_SHA256: Readonly<Record<string, string>> = {
|
||||
'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
|
||||
'linux-x64-bundle.zip': 'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check a downloaded asset against its pin. Assets we have not verified
|
||||
* ourselves are rejected rather than trusted, so an unpinned platform fails
|
||||
* loudly instead of silently running an unchecked binary.
|
||||
*/
|
||||
export function verifyPinnedBundle(
|
||||
assetName: string,
|
||||
bytes: Uint8Array,
|
||||
): { ok: true } | { ok: false; reason: string } {
|
||||
const expected = PINNED_BUNDLE_SHA256[assetName];
|
||||
if (expected === undefined) {
|
||||
return { ok: false, reason: `No pinned checksum for ${assetName}; refusing to run it.` };
|
||||
}
|
||||
const actual = sha256(bytes);
|
||||
if (actual !== expected) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Release asset name for a platform/arch pair, or null when unsupported. */
|
||||
export function resolveBundleAssetName(platform: string, arch: string): string | null {
|
||||
if (platform === 'linux') return arch === 'x64' ? 'linux-x64-bundle.zip' : null;
|
||||
if (platform === 'win32') return arch === 'x64' ? 'windows-x64-bundle.zip' : null;
|
||||
if (platform === 'darwin') {
|
||||
if (arch === 'arm64') return 'macOS-arm64-bundle.zip';
|
||||
if (arch === 'x64') return 'macOS-x64-bundle.zip';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BundleBinaries {
|
||||
javaPath: string;
|
||||
jarPath: string;
|
||||
}
|
||||
|
||||
async function walk(dir: string, depth: number, onFile: (file: string) => void): Promise<void> {
|
||||
if (depth < 0) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(full, depth - 1, onFile);
|
||||
else onFile(full);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bundled `java` executable and `MExtensionServer-*.jar` inside an
|
||||
* extracted bundle. The archive nests them a few levels deep and the exact
|
||||
* layout differs per platform, so this searches rather than assuming a path.
|
||||
*/
|
||||
export async function findBundleBinaries(rootDir: string): Promise<BundleBinaries | null> {
|
||||
const javaCandidates: string[] = [];
|
||||
const jarCandidates: string[] = [];
|
||||
|
||||
await walk(rootDir, 6, (file) => {
|
||||
const base = path.basename(file);
|
||||
if (base === 'java' || base === 'java.exe') javaCandidates.push(file);
|
||||
else if (/^MExtensionServer.*\.jar$/.test(base)) jarCandidates.push(file);
|
||||
});
|
||||
|
||||
// Prefer the shallowest match so a nested duplicate never shadows the real one.
|
||||
const byDepth = (a: string, b: string) => a.split(path.sep).length - b.split(path.sep).length;
|
||||
const javaPath = javaCandidates.sort(byDepth)[0];
|
||||
const jarPath = jarCandidates.sort(byDepth)[0];
|
||||
if (!javaPath || !jarPath) return null;
|
||||
return { javaPath, jarPath };
|
||||
}
|
||||
|
||||
/** Verify a downloaded archive against a pinned SHA-256, as Mangatan does. */
|
||||
export function sha256(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
export async function isExecutableFile(file: string): Promise<boolean> {
|
||||
try {
|
||||
const info = await stat(file);
|
||||
return info.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BundleAsset {
|
||||
tagName: string;
|
||||
assetName: string;
|
||||
downloadUrl: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name?: string;
|
||||
assets?: Array<{ name?: string; browser_download_url?: string; size?: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the asset for this platform from the pinned release. Selecting "newest"
|
||||
* instead would download a release whose checksum we never computed, so every
|
||||
* upstream publish would break the install with a mismatch.
|
||||
*/
|
||||
export function selectBundleAsset(
|
||||
releases: unknown,
|
||||
assetName: string,
|
||||
tagName: string = PINNED_BUNDLE_TAG,
|
||||
): BundleAsset | null {
|
||||
// Accepts either a single release (the by-tag endpoint) or a list.
|
||||
const candidates = Array.isArray(releases)
|
||||
? releases
|
||||
: releases && typeof releases === 'object'
|
||||
? [releases]
|
||||
: [];
|
||||
for (const release of candidates as GithubRelease[]) {
|
||||
if (release.tag_name !== tagName) continue;
|
||||
const asset = release.assets?.find((candidate) => candidate.name === assetName);
|
||||
if (asset?.browser_download_url && release.tag_name) {
|
||||
return {
|
||||
tagName: release.tag_name,
|
||||
assetName,
|
||||
downloadUrl: asset.browser_download_url,
|
||||
sizeBytes: asset.size ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
|
||||
import { allocatePort, startSidecar } from './sidecar-process';
|
||||
import type { BundleBinaries } from './sidecar-bundle';
|
||||
|
||||
const binaries: BundleBinaries = {
|
||||
javaPath: '/nonexistent/java',
|
||||
jarPath: '/tmp/MExtensionServer.jar',
|
||||
};
|
||||
|
||||
/**
|
||||
* A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches.
|
||||
* A `pid` marks a child that did spawn, which is how a kill failure is told
|
||||
* apart from a spawn failure.
|
||||
*/
|
||||
function fakeChild(onKill?: (child: EventEmitter) => void, pid?: number): ChildProcess {
|
||||
const child = new EventEmitter();
|
||||
Object.assign(child, {
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
pid,
|
||||
kill: () => {
|
||||
onKill?.(child);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return child as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
test('a failed spawn rejects instead of throwing an unhandled error event', async () => {
|
||||
const port = await allocatePort();
|
||||
const child = fakeChild();
|
||||
const spawnImpl = (() => {
|
||||
// Node emits `error` asynchronously when the binary cannot be executed.
|
||||
queueMicrotask(() => child.emit('error', new Error('spawn ENOENT')));
|
||||
return child;
|
||||
}) as unknown as typeof spawnType;
|
||||
|
||||
await assert.rejects(
|
||||
() => startSidecar({ binaries, port, readyTimeoutMs: 2000, spawnImpl }),
|
||||
/could not start.*ENOENT/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a readiness timeout shuts the child down and reports the timeout', async () => {
|
||||
const port = await allocatePort();
|
||||
// Never becomes ready, but does go down on the first signal.
|
||||
const child = fakeChild((emitter) => queueMicrotask(() => emitter.emit('exit', 0, 'SIGTERM')));
|
||||
const spawnImpl = (() => child) as unknown as typeof spawnType;
|
||||
|
||||
await assert.rejects(
|
||||
() => startSidecar({ binaries, port, readyTimeoutMs: 50, spawnImpl }),
|
||||
/did not become ready within 50ms/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a kill that fails without an exit is reported, not counted as a shutdown', async () => {
|
||||
const port = await allocatePort();
|
||||
// Signalling fails (EPERM-style) and the child never goes: it may still hold
|
||||
// the port, so the stop must not report success.
|
||||
const child = fakeChild(
|
||||
(emitter) => queueMicrotask(() => emitter.emit('error', new Error('kill EPERM'))),
|
||||
4242,
|
||||
);
|
||||
const spawnImpl = (() => child) as unknown as typeof spawnType;
|
||||
|
||||
await assert.rejects(
|
||||
() => startSidecar({ binaries, port, readyTimeoutMs: 50, stopTimeoutMs: 10, spawnImpl }),
|
||||
(error: Error) => {
|
||||
assert.match(error.message, /did not become ready within 50ms/);
|
||||
assert.match((error.cause as Error).message, /could not be killed.*EPERM/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('an early exit is reported with its code rather than waiting out the deadline', async () => {
|
||||
const port = await allocatePort();
|
||||
const child = fakeChild();
|
||||
const spawnImpl = (() => {
|
||||
queueMicrotask(() => child.emit('exit', 1, null));
|
||||
return child;
|
||||
}) as unknown as typeof spawnType;
|
||||
|
||||
await assert.rejects(
|
||||
() => startSidecar({ binaries, port, readyTimeoutMs: 2000, spawnImpl }),
|
||||
/exited before becoming ready \(code 1/,
|
||||
);
|
||||
});
|
||||
|
||||
test('onExit reports a death after readiness, including to late subscribers', async () => {
|
||||
const port = await allocatePort();
|
||||
// Fake the bridge's capabilities endpoint so startSidecar reports ready.
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve));
|
||||
const child = fakeChild();
|
||||
const spawnImpl = (() => child) as unknown as typeof spawnType;
|
||||
|
||||
try {
|
||||
const handle = await startSidecar({ binaries, port, readyTimeoutMs: 5000, spawnImpl });
|
||||
const exits: Array<{ code: number | null; signal: NodeJS.Signals | null }> = [];
|
||||
handle.onExit((info) => exits.push(info));
|
||||
assert.equal(exits.length, 0);
|
||||
|
||||
child.emit('exit', 137, null);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual([...exits], [{ code: 137, signal: null }]);
|
||||
|
||||
// A listener attached after the death still hears about it.
|
||||
handle.onExit((info) => exits.push(info));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(
|
||||
[...exits],
|
||||
[
|
||||
{ code: 137, signal: null },
|
||||
{ code: 137, signal: null },
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { AnimeBridgeClient } from './bridge-client';
|
||||
import type { BundleBinaries } from './sidecar-bundle';
|
||||
|
||||
/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
|
||||
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
||||
const READY_POLL_INTERVAL_MS = 500;
|
||||
/** How long to wait for the child to go after each signal before escalating. */
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Ask the OS for a free loopback port, then hand it to the JVM. */
|
||||
export async function allocatePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
server.close();
|
||||
reject(new Error('Could not allocate a loopback port for the anime bridge.'));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface SidecarHandle {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
/**
|
||||
* Fires once when the child process goes away, however it goes away —
|
||||
* including deliberate stops. Fires immediately for a subscriber that
|
||||
* attaches after the death, so a caller cannot miss it.
|
||||
*/
|
||||
onExit: (
|
||||
listener: (info: { code: number | null; signal: NodeJS.Signals | null }) => void,
|
||||
) => void;
|
||||
client: AnimeBridgeClient;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartSidecarOptions {
|
||||
binaries: BundleBinaries;
|
||||
port?: number;
|
||||
readyTimeoutMs?: number;
|
||||
/** How long to wait for the child to go after each stop signal. Tests only. */
|
||||
stopTimeoutMs?: number;
|
||||
spawnImpl?: typeof spawn;
|
||||
onLog?: (line: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the bridge and wait until it reports the capabilities this client
|
||||
* needs. The desktop launch contract is `java -jar MExtensionServer.jar <port>`,
|
||||
* run from the JAR's own directory.
|
||||
*/
|
||||
export async function startSidecar(options: StartSidecarOptions): Promise<SidecarHandle> {
|
||||
const { binaries } = options;
|
||||
const port = options.port ?? (await allocatePort());
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const spawnProcess = options.spawnImpl ?? spawn;
|
||||
|
||||
const child: ChildProcess = spawnProcess(
|
||||
binaries.javaPath,
|
||||
['-jar', binaries.jarPath, String(port)],
|
||||
{
|
||||
cwd: path.dirname(binaries.jarPath),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
const log = options.onLog;
|
||||
if (log) {
|
||||
child.stdout?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
|
||||
child.stderr?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
|
||||
}
|
||||
|
||||
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
let spawnError: Error | null = null;
|
||||
let killError: Error | null = null;
|
||||
const stopTimeoutMs = options.stopTimeoutMs ?? STOP_TIMEOUT_MS;
|
||||
const hasExited = new Promise<void>((resolve) => {
|
||||
child.once('exit', (code, signal) => {
|
||||
exited = { code, signal };
|
||||
resolve();
|
||||
});
|
||||
// A ChildProcess is an EventEmitter: without this listener a failed spawn
|
||||
// (a missing or non-executable java) throws in the main process instead of
|
||||
// failing the readiness loop below. `error` never proves the child is gone
|
||||
// -- only `exit` does -- so it must never set `exited`, or a kill that
|
||||
// failed would look like a clean shutdown. A spawn failure is told apart
|
||||
// from a later kill failure by the missing pid.
|
||||
child.on('error', (error: Error) => {
|
||||
if (child.pid === undefined) {
|
||||
spawnError = error;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
killError = error;
|
||||
});
|
||||
});
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
if (exited !== null) return;
|
||||
// Nothing was ever spawned, so there is nothing to signal or wait for.
|
||||
if (spawnError !== null) return;
|
||||
// Graceful first: the server exposes a shutdown endpoint.
|
||||
try {
|
||||
await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
|
||||
} catch {
|
||||
// Falling through to a signal is fine; the endpoint may already be gone.
|
||||
}
|
||||
if (exited !== null) return;
|
||||
child.kill();
|
||||
// kill() only sends the signal. Wait for the process to actually go, so a
|
||||
// restart cannot race the old one still holding the port.
|
||||
await Promise.race([hasExited, delay(stopTimeoutMs)]);
|
||||
if (exited === null) {
|
||||
child.kill('SIGKILL');
|
||||
await Promise.race([hasExited, delay(stopTimeoutMs)]);
|
||||
}
|
||||
// Never report success while the child may still hold the port: a caller
|
||||
// that restarts on the same port would race the survivor.
|
||||
if (exited === null) {
|
||||
const failedKill = killError as Error | null;
|
||||
if (failedKill !== null) {
|
||||
throw new Error(`Anime bridge could not be killed: ${failedKill.message}`, {
|
||||
cause: failedKill,
|
||||
});
|
||||
}
|
||||
throw new Error('Anime bridge did not exit after SIGKILL.');
|
||||
}
|
||||
};
|
||||
|
||||
const client = new AnimeBridgeClient({ baseUrl });
|
||||
const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (spawnError !== null) {
|
||||
throw new Error(`Anime bridge could not start: ${(spawnError as Error).message}`);
|
||||
}
|
||||
if (exited !== null) {
|
||||
const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
|
||||
throw new Error(
|
||||
`Anime bridge exited before becoming ready (code ${code}, signal ${signal}).`,
|
||||
);
|
||||
}
|
||||
// Cap the probe at the time actually left, so a short readiness budget is
|
||||
// not overrun by a single stalled capabilities request.
|
||||
if (await client.isReady(deadline - Date.now())) {
|
||||
const onExit: SidecarHandle['onExit'] = (listener) => {
|
||||
void hasExited.then(() => listener(exited ?? { code: null, signal: null }));
|
||||
};
|
||||
return { baseUrl, port, client, stop, onExit };
|
||||
}
|
||||
// Sleeping past the deadline would only delay the timeout report.
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
await delay(Math.min(READY_POLL_INTERVAL_MS, remaining));
|
||||
}
|
||||
|
||||
const timeout = new Error(
|
||||
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
|
||||
);
|
||||
// A failed shutdown must not mask why startup failed, but it must not be
|
||||
// dropped either: a surviving child still holds the port, which decides
|
||||
// whether a caller may retry on it.
|
||||
await stop().catch((error: unknown) => {
|
||||
timeout.cause = error;
|
||||
});
|
||||
throw timeout;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import {
|
||||
findTsSyncOffset,
|
||||
rewritePlaylistOrigins,
|
||||
startStreamStripProxy,
|
||||
TS_PACKET_LENGTH,
|
||||
} from './stream-strip-proxy';
|
||||
|
||||
/** A valid MPEG-TS payload: 0x47 sync byte every 188 bytes. */
|
||||
function makeTsPackets(count: number): Buffer {
|
||||
const data = Buffer.alloc(count * TS_PACKET_LENGTH, 0x11);
|
||||
for (let i = 0; i < count; i++) data[i * TS_PACKET_LENGTH] = 0x47;
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The disguise seen in the wild: a real PNG header glued before the TS data. */
|
||||
const PNG_HEADER = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
]);
|
||||
|
||||
test('findTsSyncOffset returns 0 for clean TS data', () => {
|
||||
assert.equal(findTsSyncOffset(makeTsPackets(6)), 0);
|
||||
});
|
||||
|
||||
test('findTsSyncOffset finds TS data behind a PNG prefix', () => {
|
||||
const disguised = Buffer.concat([PNG_HEADER, makeTsPackets(6)]);
|
||||
assert.equal(findTsSyncOffset(disguised), PNG_HEADER.length);
|
||||
});
|
||||
|
||||
test('findTsSyncOffset ignores a lone sync byte in the junk prefix', () => {
|
||||
const junk = Buffer.alloc(64, 0x00);
|
||||
junk[10] = 0x47; // decoy: no packet run follows it
|
||||
const disguised = Buffer.concat([junk, makeTsPackets(6)]);
|
||||
assert.equal(findTsSyncOffset(disguised), junk.length);
|
||||
});
|
||||
|
||||
test('findTsSyncOffset returns null when no TS run exists', () => {
|
||||
assert.equal(findTsSyncOffset(Buffer.alloc(4096, 0x42)), null);
|
||||
});
|
||||
|
||||
test('findTsSyncOffset returns null when the run starts past the scan limit', () => {
|
||||
const disguised = Buffer.concat([Buffer.alloc(600, 0x00), makeTsPackets(6)]);
|
||||
assert.equal(findTsSyncOffset(disguised, 500), null);
|
||||
});
|
||||
|
||||
test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lines', () => {
|
||||
const body = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:6.006,',
|
||||
'/video/aaa.ts',
|
||||
'#EXTINF:4.463,',
|
||||
'http://127.0.0.1:41569/video/bbb.ts',
|
||||
].join('\n');
|
||||
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
|
||||
assert.ok(rewritten.includes('http://127.0.0.1:9999/video/bbb.ts'));
|
||||
assert.ok(rewritten.includes('\n/video/aaa.ts\n'));
|
||||
assert.ok(!rewritten.includes('41569'));
|
||||
});
|
||||
|
||||
/* ---------- proxy end-to-end ---------- */
|
||||
|
||||
type Route = { status: number; contentType: string; body: Buffer };
|
||||
|
||||
/** Either a static routing table or a handler, for upstreams that need one. */
|
||||
async function withProxy(
|
||||
routes: Record<string, Route> | http.RequestListener,
|
||||
run: (proxyOrigin: string, upstreamOrigin: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
if (typeof routes === 'function') {
|
||||
routes(req, res);
|
||||
return;
|
||||
}
|
||||
const route = routes[req.url ?? ''];
|
||||
if (!route) {
|
||||
res.writeHead(404).end('missing');
|
||||
return;
|
||||
}
|
||||
res.writeHead(route.status, { 'content-type': route.contentType });
|
||||
res.end(route.body);
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
|
||||
|
||||
const proxy = await startStreamStripProxy({
|
||||
upstreamOrigin: () => upstreamOrigin,
|
||||
retryDelayMs: 5,
|
||||
});
|
||||
try {
|
||||
await run(proxy.origin, upstreamOrigin);
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBytes(url: string): Promise<{ status: number; body: Buffer }> {
|
||||
const response = await fetch(url);
|
||||
return { status: response.status, body: Buffer.from(await response.arrayBuffer()) };
|
||||
}
|
||||
|
||||
test('proxy strips the PNG disguise off a segment', async () => {
|
||||
const ts = makeTsPackets(8);
|
||||
const disguised = Buffer.concat([PNG_HEADER, ts]);
|
||||
await withProxy(
|
||||
{ '/video/seg.ts': { status: 200, contentType: 'video/mp2t', body: disguised } },
|
||||
async (origin) => {
|
||||
const { status, body } = await fetchBytes(`${origin}/video/seg.ts`);
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body, ts);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy passes a clean segment through unchanged', async () => {
|
||||
const ts = makeTsPackets(8);
|
||||
await withProxy(
|
||||
{ '/video/seg.ts': { status: 200, contentType: 'video/mp2t', body: ts } },
|
||||
async (origin) => {
|
||||
const { body } = await fetchBytes(`${origin}/video/seg.ts`);
|
||||
assert.deepEqual(body, ts);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy leaves non-TS bodies alone', async () => {
|
||||
const vtt = Buffer.from('WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n');
|
||||
await withProxy(
|
||||
{ '/video/sub.vtt': { status: 200, contentType: 'text/vtt', body: vtt } },
|
||||
async (origin) => {
|
||||
const { body } = await fetchBytes(`${origin}/video/sub.vtt`);
|
||||
assert.deepEqual(body, vtt);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy rewrites absolute upstream playlist entries to its own origin', async () => {
|
||||
await withProxy(
|
||||
(req, res) => {
|
||||
if (req.url !== '/video/list.m3u8') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
// The proxy rewrites the Host header to the upstream it dialled, so this
|
||||
// is that origin — the one the playlist must not leak to mpv.
|
||||
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' });
|
||||
res.end(`#EXTM3U\n#EXTINF:6,\nhttp://${req.headers.host}/video/abs.ts\n`);
|
||||
},
|
||||
async (proxyOrigin, upstreamOrigin) => {
|
||||
const { body } = await fetchBytes(`${proxyOrigin}/video/list.m3u8`);
|
||||
const text = body.toString('utf8');
|
||||
assert.ok(text.includes(`${proxyOrigin}/video/abs.ts`));
|
||||
assert.ok(!text.includes(upstreamOrigin));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy strips even when the client asks for a byte range', async () => {
|
||||
// ffmpeg opens every HLS segment with `Range: bytes=0-`. The proxy drops the
|
||||
// header, so the upstream answers 200 with the whole body and the strip
|
||||
// applies; a 206 would have been forwarded untouched.
|
||||
const ts = makeTsPackets(8);
|
||||
const disguised = Buffer.concat([PNG_HEADER, ts]);
|
||||
await withProxy(
|
||||
(req, res) => {
|
||||
if (req.headers.range !== undefined) {
|
||||
res.writeHead(206, {
|
||||
'content-type': 'image/png',
|
||||
'content-range': `bytes 0-${disguised.length - 1}/${disguised.length}`,
|
||||
});
|
||||
res.end(disguised);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'image/png' });
|
||||
res.end(disguised);
|
||||
},
|
||||
async (proxyOrigin) => {
|
||||
const response = await fetch(`${proxyOrigin}/video/seg.ts`, {
|
||||
headers: { Range: 'bytes=0-' },
|
||||
});
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(body, ts);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy forwards error statuses without touching the body', async () => {
|
||||
await withProxy(
|
||||
{ '/video/gone.ts': { status: 404, contentType: 'text/plain', body: Buffer.from('nope') } },
|
||||
async (origin) => {
|
||||
const { status, body } = await fetchBytes(`${origin}/video/gone.ts`);
|
||||
assert.equal(status, 404);
|
||||
assert.equal(body.toString('utf8'), 'nope');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Upstream that fails the first `failures` hits per path, then serves the
|
||||
* route. Mirrors the bridge right after an episode resolve: mpv's immediate
|
||||
* segment fetch errors, the same fetch a moment later works.
|
||||
*/
|
||||
async function withFlakyUpstream(
|
||||
failures: number,
|
||||
failStatus: number,
|
||||
route: Route,
|
||||
run: (proxyOrigin: string, hits: () => number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let hits = 0;
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
hits += 1;
|
||||
if (hits <= failures) {
|
||||
res.writeHead(failStatus, { 'content-type': 'text/plain' }).end('not ready');
|
||||
return;
|
||||
}
|
||||
res.writeHead(route.status, { 'content-type': route.contentType });
|
||||
res.end(route.body);
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
|
||||
const proxy = await startStreamStripProxy({
|
||||
upstreamOrigin: () => upstreamOrigin,
|
||||
retryDelayMs: 5,
|
||||
});
|
||||
try {
|
||||
await run(proxy.origin, () => hits);
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
test('proxy retries a failed segment fetch once and serves the retry', async () => {
|
||||
const ts = makeTsPackets(8);
|
||||
await withFlakyUpstream(
|
||||
1,
|
||||
404,
|
||||
{ status: 200, contentType: 'video/mp2t', body: ts },
|
||||
async (origin, hits) => {
|
||||
const { status, body } = await fetchBytes(`${origin}/video/seg.ts`);
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body, ts);
|
||||
assert.equal(hits(), 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy gives up after one retry and forwards the error', async () => {
|
||||
await withFlakyUpstream(
|
||||
Infinity,
|
||||
503,
|
||||
{ status: 200, contentType: 'video/mp2t', body: makeTsPackets(8) },
|
||||
async (origin, hits) => {
|
||||
const { status } = await fetchBytes(`${origin}/video/seg.ts`);
|
||||
assert.equal(status, 503);
|
||||
assert.equal(hits(), 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
/**
|
||||
* Loopback proxy between mpv and the anime bridge that undoes segment
|
||||
* disguises. Some hosts prepend a real image header (a 1x1 PNG in the wild) to
|
||||
* every HLS segment so scrapers see "an image"; ffmpeg then probes the segment
|
||||
* as a picture and playback dies with "no audio or video data played". Aniyomi
|
||||
* strips this in its player; mpv needs the bytes fixed before it sees them.
|
||||
*
|
||||
* Only bridge-origin `.m3u8` streams are routed through here (see
|
||||
* anime-browser-runtime). Playlist bodies get their absolute upstream origins
|
||||
* rewritten so segment requests come back through the proxy; segment bodies are
|
||||
* scanned for the first genuine MPEG-TS packet run and any junk before it is
|
||||
* dropped. Anything that is not TS (fMP4, VTT, keys) passes through untouched.
|
||||
*/
|
||||
|
||||
export const TS_PACKET_LENGTH = 188;
|
||||
const TS_SYNC_BYTE = 0x47;
|
||||
/**
|
||||
* Sync bytes that must repeat at exact packet spacing before an offset counts
|
||||
* as TS data. One or two matches happen by chance in binary data; five in a
|
||||
* row at 188-byte strides do not.
|
||||
*/
|
||||
const SYNC_RUN = 5;
|
||||
/** A disguise prefix is small; give up scanning after this much. */
|
||||
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
|
||||
/** Bytes needed to either find a run within the limit or rule one out. */
|
||||
const DECISION_BYTES = DEFAULT_SCAN_LIMIT_BYTES + (SYNC_RUN - 1) * TS_PACKET_LENGTH + 1;
|
||||
|
||||
/**
|
||||
* First offset at which a confirmed MPEG-TS packet run starts, or null when
|
||||
* the data does not look like TS at all (within the scan limit).
|
||||
*/
|
||||
export function findTsSyncOffset(
|
||||
data: Buffer,
|
||||
scanLimit = DEFAULT_SCAN_LIMIT_BYTES,
|
||||
): number | null {
|
||||
const lastConfirmable = data.length - (SYNC_RUN - 1) * TS_PACKET_LENGTH - 1;
|
||||
const end = Math.min(lastConfirmable, scanLimit);
|
||||
for (let offset = 0; offset <= end; offset++) {
|
||||
if (data[offset] !== TS_SYNC_BYTE) continue;
|
||||
let confirmed = true;
|
||||
for (let packet = 1; packet < SYNC_RUN; packet++) {
|
||||
if (data[offset + packet * TS_PACKET_LENGTH] !== TS_SYNC_BYTE) {
|
||||
confirmed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confirmed) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point absolute playlist entries at the proxy. Relative entries already
|
||||
* resolve against whatever origin served the playlist, so they need no help.
|
||||
*/
|
||||
export function rewritePlaylistOrigins(
|
||||
body: string,
|
||||
upstreamOrigin: string,
|
||||
proxyOrigin: string,
|
||||
): string {
|
||||
return body.split(upstreamOrigin).join(proxyOrigin);
|
||||
}
|
||||
|
||||
export interface StreamStripProxyOptions {
|
||||
/** Read per request so a bridge restart on a new port keeps working. */
|
||||
upstreamOrigin: () => string;
|
||||
log?: (message: string) => void;
|
||||
/** Pause before the single retry of a failed upstream GET. */
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_DELAY_MS = 400;
|
||||
/**
|
||||
* Socket timeout on the upstream GET, cleared once its headers arrive. Node's
|
||||
* http client has no deadline of its own, so a host that accepts the
|
||||
* connection and then says nothing would hang mpv on that segment forever.
|
||||
*/
|
||||
const UPSTREAM_TIMEOUT_MS = 15_000;
|
||||
|
||||
export interface StreamStripProxyHandle {
|
||||
origin: string;
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Response headers that must not be forwarded verbatim. */
|
||||
const DROPPED_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'content-length',
|
||||
]);
|
||||
|
||||
function forwardableHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
|
||||
const result: http.OutgoingHttpHeaders = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined || DROPPED_HEADERS.has(name.toLowerCase())) continue;
|
||||
result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function startStreamStripProxy(
|
||||
options: StreamStripProxyOptions,
|
||||
): Promise<StreamStripProxyHandle> {
|
||||
const log = options.log ?? (() => {});
|
||||
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
|
||||
let upstreamUrl: URL;
|
||||
try {
|
||||
upstreamUrl = new URL(req.url ?? '/', options.upstreamOrigin());
|
||||
} catch {
|
||||
res.writeHead(502).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestHeaders = forwardableHeaders(req.headers);
|
||||
delete requestHeaders.host;
|
||||
// Never forward Range: ffmpeg opens every segment with `bytes=0-`, the
|
||||
// bridge answers some of those 206, and a partial response cannot be
|
||||
// stripped (only full 200 bodies are). Byte ranges into a resource whose
|
||||
// bytes this proxy rewrites would be incoherent anyway.
|
||||
delete requestHeaders.range;
|
||||
res.on('error', () => {});
|
||||
|
||||
requestUpstream(req, res, upstreamUrl, requestHeaders, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* One delayed retry on a failed GET: right after an episode resolve, the
|
||||
* bridge (or the host behind it) can error on the very first segment
|
||||
* fetches and be fine a moment later — mpv treats a playlist full of failed
|
||||
* segments as a dead file and gives up for good.
|
||||
*/
|
||||
function requestUpstream(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
upstreamUrl: URL,
|
||||
requestHeaders: http.OutgoingHttpHeaders,
|
||||
attempt: number,
|
||||
): void {
|
||||
const mayRetry = req.method === 'GET' && attempt === 0;
|
||||
const retry = (): void => {
|
||||
setTimeout(
|
||||
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
|
||||
retryDelayMs,
|
||||
);
|
||||
};
|
||||
|
||||
const upstreamRequest = http.request(
|
||||
upstreamUrl,
|
||||
{ method: req.method, headers: requestHeaders, timeout: UPSTREAM_TIMEOUT_MS },
|
||||
(upstream) => {
|
||||
// Body streaming has its own pace; only the wait for headers is capped.
|
||||
upstreamRequest.setTimeout(0);
|
||||
const status = upstream.statusCode ?? 502;
|
||||
if (status === 404 || status >= 500) {
|
||||
if (mayRetry) {
|
||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}; retrying once`);
|
||||
upstream.resume();
|
||||
retry();
|
||||
return;
|
||||
}
|
||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
|
||||
}
|
||||
handleUpstreamResponse(req, res, upstream);
|
||||
},
|
||||
);
|
||||
// Destroying with an error routes the stall through the retry/502 path.
|
||||
upstreamRequest.on('timeout', () => {
|
||||
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
|
||||
});
|
||||
upstreamRequest.on('error', (error) => {
|
||||
if (mayRetry) {
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
|
||||
retry();
|
||||
return;
|
||||
}
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}`);
|
||||
if (!res.headersSent) res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
upstreamRequest.end();
|
||||
}
|
||||
|
||||
function handleUpstreamResponse(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
upstream: http.IncomingMessage,
|
||||
): void {
|
||||
const status = upstream.statusCode ?? 502;
|
||||
const pathname = (req.url ?? '').split('?', 1)[0] ?? '';
|
||||
const contentType = String(upstream.headers['content-type'] ?? '');
|
||||
const isPlaylist = pathname.endsWith('.m3u8') || contentType.includes('mpegurl');
|
||||
|
||||
upstream.on('error', () => res.destroy());
|
||||
|
||||
// Only a full 200 body is safe to modify; everything else (errors, range
|
||||
// responses, HEAD) forwards untouched.
|
||||
if (status !== 200 || req.method === 'HEAD') {
|
||||
res.writeHead(status, forwardableHeaders(upstream.headers));
|
||||
upstream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaylist) {
|
||||
const chunks: Buffer[] = [];
|
||||
upstream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
upstream.on('end', () => {
|
||||
const body = rewritePlaylistOrigins(
|
||||
Buffer.concat(chunks).toString('utf8'),
|
||||
options.upstreamOrigin(),
|
||||
origin,
|
||||
);
|
||||
res.writeHead(status, {
|
||||
...forwardableHeaders(upstream.headers),
|
||||
'content-length': Buffer.byteLength(body, 'utf8'),
|
||||
});
|
||||
res.end(body);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
stripSegment(res, upstream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer just enough of the body to find (or rule out) a TS packet run,
|
||||
* drop everything before it, then stream the rest through untouched.
|
||||
*/
|
||||
function stripSegment(res: http.ServerResponse, upstream: http.IncomingMessage): void {
|
||||
const chunks: Buffer[] = [];
|
||||
let buffered = 0;
|
||||
|
||||
const respond = (data: Buffer, remainderFollows: boolean): void => {
|
||||
const offset = findTsSyncOffset(data) ?? 0;
|
||||
if (offset > 0) log(`[stream-proxy] stripped ${offset} disguise bytes off a segment`);
|
||||
const body = offset > 0 ? data.subarray(offset) : data;
|
||||
|
||||
const headers = forwardableHeaders(upstream.headers);
|
||||
const upstreamLength = Number(upstream.headers['content-length']);
|
||||
if (remainderFollows) {
|
||||
if (Number.isFinite(upstreamLength)) headers['content-length'] = upstreamLength - offset;
|
||||
} else {
|
||||
headers['content-length'] = body.length;
|
||||
}
|
||||
|
||||
res.writeHead(upstream.statusCode ?? 200, headers);
|
||||
res.write(body);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer): void => {
|
||||
chunks.push(chunk);
|
||||
buffered += chunk.length;
|
||||
if (buffered < DECISION_BYTES) return;
|
||||
upstream.off('data', onData);
|
||||
upstream.off('end', onEnd);
|
||||
respond(Buffer.concat(chunks), true);
|
||||
upstream.pipe(res);
|
||||
};
|
||||
const onEnd = (): void => {
|
||||
respond(Buffer.concat(chunks), false);
|
||||
res.end();
|
||||
};
|
||||
upstream.on('data', onData);
|
||||
upstream.on('end', onEnd);
|
||||
}
|
||||
|
||||
let origin = '';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
origin = `http://127.0.0.1:${port}`;
|
||||
resolve({
|
||||
origin,
|
||||
port,
|
||||
close: () =>
|
||||
new Promise<void>((resolveClose) => {
|
||||
server.closeAllConnections?.();
|
||||
server.close(() => resolveClose());
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
cacheSubtitleTracks,
|
||||
removeSubtitleCache,
|
||||
resolveSubtitleExtension,
|
||||
sniffSubtitleExtension,
|
||||
subtitleExtensionFromUrl,
|
||||
type SubtitleCacheIo,
|
||||
} from './subtitle-cache';
|
||||
|
||||
interface FakeIo extends SubtitleCacheIo {
|
||||
written: Map<string, string>;
|
||||
removed: string[];
|
||||
requests: Array<{ url: string; headers: Record<string, string> }>;
|
||||
}
|
||||
|
||||
function fakeIo(bodies: Record<string, string | { status: number }>): FakeIo {
|
||||
const written = new Map<string, string>();
|
||||
const removed: string[] = [];
|
||||
const requests: Array<{ url: string; headers: Record<string, string> }> = [];
|
||||
|
||||
return {
|
||||
written,
|
||||
removed,
|
||||
requests,
|
||||
async fetch(url, init) {
|
||||
requests.push({ url, headers: init.headers });
|
||||
const body = bodies[url];
|
||||
if (body === undefined) throw new Error(`unexpected fetch: ${url}`);
|
||||
if (typeof body !== 'string') {
|
||||
return { ok: false, status: body.status, arrayBuffer: async () => new ArrayBuffer(0) };
|
||||
}
|
||||
const bytes = Buffer.from(body, 'utf8');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () =>
|
||||
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
|
||||
};
|
||||
},
|
||||
async makeTempDir(prefix) {
|
||||
return `${prefix}test`;
|
||||
},
|
||||
async writeFile(filePath, bytes) {
|
||||
written.set(filePath, Buffer.from(bytes).toString('utf8'));
|
||||
},
|
||||
async removeDir(dir) {
|
||||
removed.push(dir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SRT = '1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n';
|
||||
const ASS = '[Script Info]\nScriptType: v4.00+\n\n[Events]\n';
|
||||
|
||||
test('content decides the extension before the url does', () => {
|
||||
assert.equal(sniffSubtitleExtension(ASS), 'ass');
|
||||
assert.equal(sniffSubtitleExtension(SRT), 'srt');
|
||||
assert.equal(sniffSubtitleExtension('WEBVTT\n\n00:01.000 --> 00:02.000\n'), 'vtt');
|
||||
assert.equal(sniffSubtitleExtension('nothing recognisable'), null);
|
||||
// An ASS body served from a .srt URL keeps the extension its parser needs.
|
||||
assert.equal(resolveSubtitleExtension('http://host/sub.srt', ASS), 'ass');
|
||||
});
|
||||
|
||||
test('a bom or leading whitespace does not hide the format marker', () => {
|
||||
assert.equal(sniffSubtitleExtension(`${ASS}`), 'ass');
|
||||
assert.equal(sniffSubtitleExtension(`\n\n${SRT}`), 'srt');
|
||||
});
|
||||
|
||||
test('the url extension is the fallback, and only for formats we know', () => {
|
||||
assert.equal(subtitleExtensionFromUrl('http://host/a/b.ASS?x=1'), 'ass');
|
||||
assert.equal(subtitleExtensionFromUrl('http://host/video/token'), null);
|
||||
assert.equal(subtitleExtensionFromUrl('http://host/a.mp4'), null);
|
||||
// Nothing to go on: mpv can still probe past a wrong name.
|
||||
assert.equal(resolveSubtitleExtension('http://host/video/token', 'unknown'), 'srt');
|
||||
});
|
||||
|
||||
test('tracks are downloaded to a temp dir and handed back as file paths', async () => {
|
||||
const io = fakeIo({ 'http://bridge/sub/ja': SRT, 'http://bridge/sub/en': ASS });
|
||||
const result = await cacheSubtitleTracks({
|
||||
tracks: [
|
||||
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
|
||||
{ url: 'http://bridge/sub/en', lang: 'English' },
|
||||
],
|
||||
headers: { Referer: 'https://host/' },
|
||||
io,
|
||||
});
|
||||
|
||||
assert.ok(result.dir);
|
||||
assert.deepEqual(
|
||||
result.tracks.map((track) => path.basename(track.url)),
|
||||
['track-0.srt', 'track-1.ass'],
|
||||
);
|
||||
assert.ok(result.tracks.every((track) => track.local));
|
||||
assert.equal(io.written.get(result.tracks[0]!.url), SRT);
|
||||
// The stream's headers ride along; some hosts gate the subtitle URL too.
|
||||
assert.deepEqual(io.requests[0]!.headers, { Referer: 'https://host/' });
|
||||
});
|
||||
|
||||
test('a failed download keeps its url so the episode still plays', async () => {
|
||||
const io = fakeIo({ 'http://bridge/sub/ja': SRT, 'http://bridge/sub/en': { status: 404 } });
|
||||
const logged: string[] = [];
|
||||
const result = await cacheSubtitleTracks({
|
||||
tracks: [
|
||||
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
|
||||
{ url: 'http://bridge/sub/en', lang: 'English' },
|
||||
],
|
||||
io,
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(result.tracks[0]!.local, true);
|
||||
assert.equal(result.tracks[1]!.local, false);
|
||||
assert.equal(result.tracks[1]!.url, 'http://bridge/sub/en');
|
||||
assert.ok(logged.some((message) => message.includes('404')));
|
||||
// One track survived, so the directory stays.
|
||||
assert.ok(result.dir);
|
||||
assert.deepEqual(io.removed, []);
|
||||
});
|
||||
|
||||
test('an oversized streamed subtitle stops early and falls back to its remote url', async () => {
|
||||
const io = fakeIo({});
|
||||
const logged: string[] = [];
|
||||
let chunksRead = 0;
|
||||
let buffered = false;
|
||||
const chunk = new Uint8Array(20 * 1024 * 1024);
|
||||
const body = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
pull(controller) {
|
||||
chunksRead += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
io.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body,
|
||||
async arrayBuffer() {
|
||||
buffered = true;
|
||||
throw new Error('stream should not be buffered');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await cacheSubtitleTracks({
|
||||
tracks: [{ url: 'http://bridge/sub/oversized', lang: 'Japanese' }],
|
||||
io,
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(buffered, false);
|
||||
assert.equal(chunksRead, 2);
|
||||
assert.equal(result.dir, null);
|
||||
assert.deepEqual(result.tracks, [
|
||||
{
|
||||
url: 'http://bridge/sub/oversized',
|
||||
lang: 'Japanese',
|
||||
sourceUrl: 'http://bridge/sub/oversized',
|
||||
local: false,
|
||||
},
|
||||
]);
|
||||
assert.ok(logged.some((message) => message.includes('response too large')));
|
||||
});
|
||||
|
||||
test('a directory with nothing in it is removed and not reported', async () => {
|
||||
const io = fakeIo({ 'http://bridge/sub/ja': { status: 500 } });
|
||||
const result = await cacheSubtitleTracks({
|
||||
tracks: [{ url: 'http://bridge/sub/ja', lang: 'Japanese' }],
|
||||
io,
|
||||
});
|
||||
|
||||
assert.equal(result.dir, null);
|
||||
assert.equal(result.tracks[0]!.local, false);
|
||||
assert.equal(io.removed.length, 1);
|
||||
});
|
||||
|
||||
test('duplicate and empty urls are dropped before anything is fetched', async () => {
|
||||
const io = fakeIo({ 'http://bridge/sub/ja': SRT });
|
||||
const result = await cacheSubtitleTracks({
|
||||
tracks: [
|
||||
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
|
||||
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
|
||||
{ url: '', lang: 'English' },
|
||||
],
|
||||
io,
|
||||
});
|
||||
|
||||
assert.equal(result.tracks.length, 1);
|
||||
assert.equal(io.requests.length, 1);
|
||||
});
|
||||
|
||||
test('no tracks means no temp directory at all', async () => {
|
||||
const io = fakeIo({});
|
||||
const result = await cacheSubtitleTracks({ tracks: [], io });
|
||||
|
||||
assert.deepEqual(result, { dir: null, tracks: [] });
|
||||
assert.equal(io.written.size, 0);
|
||||
});
|
||||
|
||||
test('cleanup is best effort and never throws', async () => {
|
||||
const io = fakeIo({});
|
||||
io.removeDir = async () => {
|
||||
throw new Error('EBUSY');
|
||||
};
|
||||
await removeSubtitleCache('/tmp/subminer-anime-subtitles-x', io);
|
||||
// A null directory is the common case after a source with no subtitles.
|
||||
await removeSubtitleCache(null, io);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Extension subtitle tracks arrive as URLs on the bridge's loopback proxy, and
|
||||
* mpv is perfectly happy to stream them. alass is not: it needs a file on disk
|
||||
* to use as the timing reference, and the subsync path rejects an external
|
||||
* track whose `external-filename` is not an existing file. So every track is
|
||||
* downloaded to a temp directory first and mpv is given the local path, the
|
||||
* same way the Jellyfin preload caches its delivery URLs.
|
||||
*
|
||||
* The directory outlives the `sub-add` — alass reads it mid-playback — and is
|
||||
* removed when the next episode starts or the runtime shuts down.
|
||||
*/
|
||||
|
||||
/** Extensions mpv and alass both recognise off a filename. */
|
||||
const KNOWN_SUBTITLE_EXTENSIONS = new Set([
|
||||
'srt',
|
||||
'ass',
|
||||
'ssa',
|
||||
'vtt',
|
||||
'sub',
|
||||
'ttml',
|
||||
'smi',
|
||||
'sbv',
|
||||
]);
|
||||
|
||||
/** What an unrecognisable track is named; mpv still probes the content. */
|
||||
const DEFAULT_SUBTITLE_EXTENSION = 'srt';
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Anything this large is not a subtitle file, and is not worth buffering. */
|
||||
const MAX_SUBTITLE_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** How much of the body is decoded to guess the format. */
|
||||
const SNIFF_BYTES = 1024;
|
||||
|
||||
export interface SubtitleTrackRef {
|
||||
url: string;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
export interface CachedSubtitleTrack extends SubtitleTrackRef {
|
||||
/** Where the track came from, kept for logs. */
|
||||
sourceUrl: string;
|
||||
/** False when the download failed and `url` is still the remote URL. */
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
export interface SubtitleCacheResult {
|
||||
/** The temp directory to remove later, or null when nothing was cached. */
|
||||
dir: string | null;
|
||||
tracks: CachedSubtitleTrack[];
|
||||
}
|
||||
|
||||
interface FetchResponseLike {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body?: ReadableStream<Uint8Array> | null;
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
export interface SubtitleCacheIo {
|
||||
fetch: (
|
||||
url: string,
|
||||
init: { headers: Record<string, string>; signal: AbortSignal },
|
||||
) => Promise<FetchResponseLike>;
|
||||
makeTempDir: (prefix: string) => Promise<string>;
|
||||
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
removeDir: (dir: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CacheSubtitleTracksOptions {
|
||||
tracks: SubtitleTrackRef[];
|
||||
/** Headers the stream was resolved with; some hosts gate subtitles too. */
|
||||
headers?: Record<string, string>;
|
||||
io?: SubtitleCacheIo;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function createSubtitleCacheIo(): SubtitleCacheIo {
|
||||
return {
|
||||
fetch: (url, init) => fetch(url, init),
|
||||
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
|
||||
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
|
||||
removeDir: (dir) => fs.promises.rm(dir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess a subtitle format from the start of the file.
|
||||
*
|
||||
* Bridge subtitle URLs are opaque tokens far more often than they are
|
||||
* filenames, so the content is the only reliable signal. mpv and alass both
|
||||
* pick their parser off the extension, and a `.srt` holding ASS is a parse
|
||||
* error rather than a mistimed subtitle.
|
||||
*/
|
||||
export function sniffSubtitleExtension(head: string): string | null {
|
||||
const text = head.replace(/^\uFEFF/, '').trimStart();
|
||||
if (/^\[(script info|v4\+? styles|events)\]/i.test(text)) return 'ass';
|
||||
if (/^WEBVTT(\s|$)/.test(text)) return 'vtt';
|
||||
if (/^<\?xml/i.test(text) && /<tt[\s>]|ttml/i.test(text)) return 'ttml';
|
||||
// Cue-numbered and bare-timestamp SRT; the `.` separator is a common variant.
|
||||
if (/^(\d+\s*\r?\n)?\d{1,3}:\d{2}:\d{2}[,.]\d{1,3}\s*-->/.test(text)) return 'srt';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The URL's own extension, when it names a format we know. */
|
||||
export function subtitleExtensionFromUrl(url: string): string | null {
|
||||
const urlPath = (() => {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
const extension = path.extname(urlPath).slice(1).toLowerCase();
|
||||
return KNOWN_SUBTITLE_EXTENSIONS.has(extension) ? extension : null;
|
||||
}
|
||||
|
||||
/** Content first, then the URL, then a guess mpv can still probe past. */
|
||||
export function resolveSubtitleExtension(url: string, head: string): string {
|
||||
return (
|
||||
sniffSubtitleExtension(head) ?? subtitleExtensionFromUrl(url) ?? DEFAULT_SUBTITLE_EXTENSION
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeByUrl(tracks: SubtitleTrackRef[]): SubtitleTrackRef[] {
|
||||
const seen = new Set<string>();
|
||||
return tracks.filter((track) => {
|
||||
if (track.url.length === 0 || seen.has(track.url)) return false;
|
||||
seen.add(track.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadTrack(
|
||||
io: SubtitleCacheIo,
|
||||
dir: string,
|
||||
index: number,
|
||||
track: SubtitleTrackRef,
|
||||
headers: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
const response = await io.fetch(track.url, { headers, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
bytes = await readBounded(response, MAX_SUBTITLE_BYTES);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (bytes.byteLength === 0) {
|
||||
throw new Error('empty response');
|
||||
}
|
||||
if (bytes.byteLength > MAX_SUBTITLE_BYTES) {
|
||||
throw new Error(`response too large (${bytes.byteLength} bytes)`);
|
||||
}
|
||||
|
||||
const head = Buffer.from(bytes.subarray(0, SNIFF_BYTES)).toString('utf8');
|
||||
const extension = resolveSubtitleExtension(track.url, head);
|
||||
const filePath = path.join(dir, `track-${index}.${extension}`);
|
||||
// Written byte for byte: re-encoding would corrupt a non-UTF-8 track that
|
||||
// mpv's own charset detection would otherwise handle.
|
||||
await io.writeFile(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function readBounded(response: FetchResponseLike, maxBytes: number): Promise<Uint8Array> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
throw new Error(`response too large (${bytes.byteLength} bytes)`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
throw new Error(`response too large (${total} bytes)`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download every subtitle track to a fresh temp directory.
|
||||
*
|
||||
* A track that fails to download keeps its remote URL, so a dead subtitle
|
||||
* server costs the alass reference rather than the episode.
|
||||
*/
|
||||
export async function cacheSubtitleTracks(
|
||||
options: CacheSubtitleTracksOptions,
|
||||
): Promise<SubtitleCacheResult> {
|
||||
const io = options.io ?? createSubtitleCacheIo();
|
||||
const tracks = dedupeByUrl(options.tracks);
|
||||
if (tracks.length === 0) return { dir: null, tracks: [] };
|
||||
|
||||
const dir = await io.makeTempDir(path.join(os.tmpdir(), 'subminer-anime-subtitles-'));
|
||||
const cached = await Promise.all(
|
||||
tracks.map(async (track, index): Promise<CachedSubtitleTrack> => {
|
||||
try {
|
||||
const filePath = await downloadTrack(io, dir, index, track, options.headers ?? {});
|
||||
return { url: filePath, lang: track.lang, sourceUrl: track.url, local: true };
|
||||
} catch (error) {
|
||||
options.log?.(
|
||||
`[anime-browser] subtitle download failed (${track.lang || 'unknown'}): ` +
|
||||
describeError(error),
|
||||
);
|
||||
return { url: track.url, lang: track.lang, sourceUrl: track.url, local: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!cached.some((track) => track.local)) {
|
||||
await removeSubtitleCache(dir, io);
|
||||
return { dir: null, tracks: cached };
|
||||
}
|
||||
return { dir, tracks: cached };
|
||||
}
|
||||
|
||||
/** Remove a cache directory. Never throws: cleanup is best effort. */
|
||||
export async function removeSubtitleCache(
|
||||
dir: string | null,
|
||||
io: SubtitleCacheIo = createSubtitleCacheIo(),
|
||||
): Promise<void> {
|
||||
if (!dir) return;
|
||||
try {
|
||||
await io.removeDir(dir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Wire types for the M-Extension-Server bridge, which runs Aniyomi
|
||||
* (`eu.kanade.tachiyomi.animeextension`) APKs on a desktop JVM and exposes
|
||||
* them over loopback HTTP.
|
||||
*
|
||||
* Field names mirror the server's JSON exactly, including Kotlin/OkHttp
|
||||
* internals that leak into the payload (see `OkHttpHeaders`).
|
||||
*/
|
||||
|
||||
/** Marker key the server uses to select a source inside a SourceFactory APK. */
|
||||
export const BRIDGE_CONTEXT_KEY = '__mangatan_bridge_context__';
|
||||
|
||||
/** Handshake shape from `GET /capabilities`. */
|
||||
export interface BridgeCapabilities {
|
||||
mangatanMihonBridge?: number;
|
||||
sourceFactory?: boolean;
|
||||
preferenceCallbacks?: boolean;
|
||||
youtubeResolver?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OkHttp serializes `Headers` as a flat alternating name/value array under an
|
||||
* internal field name. Kept verbatim so parsing stays honest about the source.
|
||||
*/
|
||||
export interface OkHttpHeaders {
|
||||
namesAndValues$okhttp?: string[];
|
||||
}
|
||||
|
||||
export interface BridgeTrack {
|
||||
url?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
/** One playable stream returned by an extension's `getVideoList`. */
|
||||
export interface BridgeVideo {
|
||||
/** Page/embed URL the stream was extracted from. */
|
||||
url?: string;
|
||||
/** Display label, e.g. "1080p". */
|
||||
quality?: string;
|
||||
/**
|
||||
* Playable media URL. Normally a `/video/<token>` proxy URL on the bridge
|
||||
* itself, valid only while that server process is alive.
|
||||
*/
|
||||
videoUrl?: string;
|
||||
headers?: OkHttpHeaders;
|
||||
audioTracks?: BridgeTrack[];
|
||||
subtitleTracks?: BridgeTrack[];
|
||||
}
|
||||
|
||||
export interface BridgeEpisode {
|
||||
name?: string;
|
||||
url?: string;
|
||||
date_upload?: number;
|
||||
scanlator?: string;
|
||||
episode_number?: number;
|
||||
}
|
||||
|
||||
export interface BridgeAnime {
|
||||
url?: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
genres?: string[];
|
||||
status?: number;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/** One source inside an extension APK. A SourceFactory APK yields several. */
|
||||
export interface BridgeSourceDescriptor {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
lang?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface BridgeAnimePage {
|
||||
animes?: BridgeAnime[];
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
/** The server reports failures as HTTP 200 with an error body. */
|
||||
export interface BridgeErrorBody {
|
||||
error?: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
/** A source preference entry, passed through to the extension unchanged. */
|
||||
export interface BridgePreference {
|
||||
key: string;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
/** Normalized stream, ready to hand to mpv. */
|
||||
export interface ResolvedStream {
|
||||
url: string;
|
||||
quality: string;
|
||||
headers: Record<string, string>;
|
||||
subtitles: Array<{ url: string; lang: string }>;
|
||||
audios: Array<{ url: string; lang: string }>;
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { describe, el } from './dom';
|
||||
import { sourceOptionLabel, summarizeSearch } from './format';
|
||||
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
|
||||
import { createExtensionsPanel } from './extensions-panel';
|
||||
import { createDetailPanel } from './detail-panel';
|
||||
import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields';
|
||||
import {
|
||||
beginBrowse,
|
||||
beginNextPage,
|
||||
createBrowseState,
|
||||
failBrowse,
|
||||
finishBrowse,
|
||||
soleBrowseRequest,
|
||||
takeUnseenEntries,
|
||||
} from './browse-state';
|
||||
import type { BrowseRequest } from './browse-state';
|
||||
import { ALL_SOURCES_ID } from '../types/anime-browser';
|
||||
import type {
|
||||
AnimeBrowserAPI,
|
||||
AnimeBrowserBridgeState,
|
||||
AnimeBrowserEntry,
|
||||
AnimeBrowserSource,
|
||||
} from '../types/anime-browser';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
animeBrowserAPI: AnimeBrowserAPI;
|
||||
}
|
||||
}
|
||||
|
||||
const api = window.animeBrowserAPI;
|
||||
|
||||
const searchForm = el<HTMLFormElement>('search-form');
|
||||
const searchInput = el<HTMLInputElement>('search-input');
|
||||
const searchButton = el<HTMLButtonElement>('search-button');
|
||||
const sourceSelect = el<HTMLSelectElement>('source-select');
|
||||
const grid = el<HTMLDivElement>('grid');
|
||||
const gridEmpty = el<HTMLParagraphElement>('grid-empty');
|
||||
const loadMoreButton = el<HTMLButtonElement>('load-more');
|
||||
const banner = el<HTMLDivElement>('bridge-banner');
|
||||
const bannerMessage = el<HTMLSpanElement>('bridge-message');
|
||||
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
|
||||
const bannerMeterFill = el<HTMLElement>('bridge-meter-fill');
|
||||
const statusMessage = el<HTMLSpanElement>('status-message');
|
||||
const browseTab = el<HTMLButtonElement>('tab-browse');
|
||||
const extensionsTab = el<HTMLButtonElement>('tab-extensions');
|
||||
const settingsTab = el<HTMLButtonElement>('tab-settings');
|
||||
const layout = el<HTMLElement>('layout');
|
||||
const extensionsPanel = el<HTMLElement>('extensions');
|
||||
const settingsPanel = el<HTMLElement>('settings');
|
||||
const settingsFields = el<HTMLDivElement>('settings-fields');
|
||||
const settingsTitle = el<HTMLHeadingElement>('settings-title');
|
||||
|
||||
/** Last source accepted by the main process, used to roll back a rejected change. */
|
||||
let selectedSourceId: string | null = null;
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
|
||||
type View = 'browse' | 'extensions' | 'settings';
|
||||
|
||||
let currentView: View = 'browse';
|
||||
|
||||
/**
|
||||
* Show one view at a time. The panels used to sit above the results, which left
|
||||
* a long extension list scrolling inside a sliver of the window; as tabs each
|
||||
* one gets the whole content region.
|
||||
*/
|
||||
function setView(view: View): void {
|
||||
currentView = view;
|
||||
layout.classList.toggle('hidden', view !== 'browse');
|
||||
extensionsPanel.classList.toggle('hidden', view !== 'extensions');
|
||||
settingsPanel.classList.toggle('hidden', view !== 'settings');
|
||||
browseTab.setAttribute('aria-selected', String(view === 'browse'));
|
||||
extensionsTab.setAttribute('aria-selected', String(view === 'extensions'));
|
||||
settingsTab.setAttribute('aria-selected', String(view === 'settings'));
|
||||
}
|
||||
|
||||
function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): void {
|
||||
statusMessage.textContent = message;
|
||||
statusMessage.parentElement?.setAttribute('data-tone', tone);
|
||||
}
|
||||
|
||||
const detailPanel = createDetailPanel({ api, setStatus });
|
||||
|
||||
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
|
||||
idle: 'Starting the extension bridge',
|
||||
locating: 'Looking up the extension bridge release',
|
||||
downloading: 'Downloading the extension bridge',
|
||||
verifying: 'Verifying the download',
|
||||
extracting: 'Unpacking the extension bridge',
|
||||
starting: 'Starting the extension bridge',
|
||||
ready: 'Bridge ready',
|
||||
failed: 'Bridge failed to start',
|
||||
};
|
||||
|
||||
const BUSY_STAGES = new Set([
|
||||
'idle',
|
||||
'locating',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'extracting',
|
||||
'starting',
|
||||
]);
|
||||
|
||||
function renderBridgeState(state: AnimeBrowserBridgeState): void {
|
||||
const busy = BUSY_STAGES.has(state.stage);
|
||||
banner.dataset.stage = state.stage;
|
||||
banner.dataset.busy = String(busy);
|
||||
|
||||
// Once ready with nothing to report, the banner has nothing to say.
|
||||
const hide = state.stage === 'ready' && state.message === null;
|
||||
banner.classList.toggle('hidden', hide);
|
||||
bannerMessage.textContent = state.message ?? BRIDGE_LABELS[state.stage];
|
||||
|
||||
const showMeter = state.progress !== null;
|
||||
bannerMeter.classList.toggle('hidden', !showMeter);
|
||||
if (state.progress !== null) {
|
||||
bannerMeterFill.style.width = `${Math.round(state.progress * 100)}%`;
|
||||
}
|
||||
|
||||
const ready = state.stage === 'ready';
|
||||
searchInput.disabled = !ready;
|
||||
searchButton.disabled = !ready;
|
||||
}
|
||||
|
||||
/* ---------- source picker ---------- */
|
||||
|
||||
/**
|
||||
* The picker lists every installed source, plus an "All sources" entry that
|
||||
* searches them together. That entry only earns its place with more than one
|
||||
* source installed.
|
||||
*/
|
||||
function renderSources(sources: AnimeBrowserSource[], selectedId: string | null): void {
|
||||
const options: HTMLOptionElement[] = [];
|
||||
|
||||
if (sources.length > 1) {
|
||||
const all = document.createElement('option');
|
||||
all.value = ALL_SOURCES_ID;
|
||||
all.textContent = `All sources (${sources.length})`;
|
||||
all.selected = selectedId === ALL_SOURCES_ID;
|
||||
options.push(all);
|
||||
}
|
||||
|
||||
for (const source of sources) {
|
||||
const option = document.createElement('option');
|
||||
option.value = source.id;
|
||||
option.textContent = sourceOptionLabel(source);
|
||||
option.selected = source.id === selectedId;
|
||||
options.push(option);
|
||||
}
|
||||
|
||||
sourceSelect.replaceChildren(...options);
|
||||
sourceSelect.disabled = options.length <= 1;
|
||||
selectedSourceId = selectedId;
|
||||
}
|
||||
|
||||
function searchingAllSources(): boolean {
|
||||
return sourceSelect.value === ALL_SOURCES_ID;
|
||||
}
|
||||
|
||||
/* ---------- results ---------- */
|
||||
|
||||
function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonElement {
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = 'card';
|
||||
|
||||
const art = document.createElement('div');
|
||||
art.className = 'card-art';
|
||||
if (entry.thumbnailUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = entry.thumbnailUrl;
|
||||
img.alt = '';
|
||||
img.loading = 'lazy';
|
||||
// A dead cover should fall back to the initial, not a broken-image icon.
|
||||
img.addEventListener('error', () => {
|
||||
img.remove();
|
||||
art.classList.add('is-empty');
|
||||
});
|
||||
art.append(img);
|
||||
} else {
|
||||
art.classList.add('is-empty');
|
||||
}
|
||||
art.dataset.initial = entry.title.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
if (showSource) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'card-source';
|
||||
badge.textContent = entry.sourceName;
|
||||
art.append(badge);
|
||||
}
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'card-title';
|
||||
title.textContent = entry.title;
|
||||
|
||||
card.append(art, title);
|
||||
card.title = showSource ? `${entry.title} — ${entry.sourceName}` : entry.title;
|
||||
card.addEventListener('click', () => {
|
||||
void detailPanel.open(entry);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Drops every card and the empty-state message so a fresh page can build up. */
|
||||
function resetGrid(): void {
|
||||
seenEntries.clear();
|
||||
grid.replaceChildren();
|
||||
gridEmpty.classList.add('hidden');
|
||||
}
|
||||
|
||||
function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void {
|
||||
// Which source a cover came from only matters when they are mixed together.
|
||||
resetGrid();
|
||||
appendEntries(entries);
|
||||
|
||||
const empty = grid.childElementCount === 0;
|
||||
gridEmpty.classList.toggle('hidden', !empty);
|
||||
gridEmpty.textContent = emptyMessage;
|
||||
}
|
||||
|
||||
/** Streamed results land at the end of the grid, in arrival order. */
|
||||
function appendEntries(entries: AnimeBrowserEntry[]): void {
|
||||
const showSource = searchingAllSources();
|
||||
const unseen = takeUnseenEntries(entries, seenEntries);
|
||||
grid.append(...unseen.map((entry) => createCard(entry, showSource)));
|
||||
if (unseen.length > 0) gridEmpty.classList.add('hidden');
|
||||
}
|
||||
|
||||
/* ---------- streamed search ---------- */
|
||||
|
||||
/**
|
||||
* Results are pushed per source while the search call is still pending, so a
|
||||
* fast source is on screen before a slow one answers. The grid is built from
|
||||
* those pushes; the awaited result then settles the final status line (and
|
||||
* backfills the grid if no push ever arrived).
|
||||
*/
|
||||
let progress = idleSearchProgress();
|
||||
|
||||
let browseState = createBrowseState();
|
||||
const inFlightBrowses = new Map<number, BrowseRequest>();
|
||||
let activeStreamRequestId = 0;
|
||||
const seenEntries = new Set<string>();
|
||||
|
||||
function renderLoadMore(): void {
|
||||
const loadingNextPage = browseState.loading && browseState.page > 1;
|
||||
loadMoreButton.classList.toggle('hidden', !browseState.hasNextPage && !loadingNextPage);
|
||||
loadMoreButton.disabled = browseState.loading;
|
||||
loadMoreButton.textContent = loadingNextPage ? 'Loading…' : 'Load more';
|
||||
}
|
||||
|
||||
api.onSearchUpdate((update) => {
|
||||
const applied = applySearchUpdate(progress, update);
|
||||
if (!applied) return; // A superseded search; let it run out quietly.
|
||||
progress = applied.progress;
|
||||
|
||||
if (applied.started) {
|
||||
// The runtime token does not carry the renderer request id. When calls
|
||||
// overlap their starts may arrive in either order, so stream only when the
|
||||
// association is unambiguous; the final response still backfills the grid.
|
||||
const request = soleBrowseRequest(inFlightBrowses);
|
||||
activeStreamRequestId = request?.id ?? 0;
|
||||
const append = request?.append === true;
|
||||
if (!append) resetGrid();
|
||||
return;
|
||||
}
|
||||
if (activeStreamRequestId !== browseState.requestId) return;
|
||||
if (applied.entries.length > 0) appendEntries(applied.entries);
|
||||
if (!progress.done) {
|
||||
setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info');
|
||||
}
|
||||
});
|
||||
|
||||
async function runBrowse(request: BrowseRequest): Promise<void> {
|
||||
inFlightBrowses.set(request.id, request);
|
||||
renderLoadMore();
|
||||
|
||||
try {
|
||||
const result = request.query
|
||||
? await api.search(request.query, request.page)
|
||||
: await api.getPopular(request.page);
|
||||
if (request.id !== browseState.requestId) return;
|
||||
browseState = finishBrowse(browseState, request.id, result.hasNextPage);
|
||||
renderLoadMore();
|
||||
|
||||
// Every source failing is an error, not an empty result set.
|
||||
if (result.entries.length === 0 && result.failures.length > 0) {
|
||||
const first = result.failures[0];
|
||||
if (!request.append) renderEntries([], `${first?.sourceName}: ${first?.error}`);
|
||||
setStatus(summarizeSearch(result), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// The final response backfills a host without streaming. Entries already
|
||||
// pushed by the stream are filtered out, including sources that repeat a
|
||||
// final page while another source still has more.
|
||||
appendEntries(result.entries);
|
||||
if (!request.append && grid.childElementCount === 0) {
|
||||
renderEntries(
|
||||
[],
|
||||
request.query ? `Nothing found for “${request.query}”.` : 'This source returned nothing.',
|
||||
);
|
||||
}
|
||||
setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info');
|
||||
} catch (error) {
|
||||
if (request.id !== browseState.requestId) return;
|
||||
browseState = failBrowse(browseState, request);
|
||||
renderLoadMore();
|
||||
if (!request.append) renderEntries([], describe(error));
|
||||
setStatus(describe(error), 'error');
|
||||
} finally {
|
||||
inFlightBrowses.delete(request.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(query: string): Promise<void> {
|
||||
const started = beginBrowse(browseState, query);
|
||||
browseState = started.state;
|
||||
// A new search means new results; leave the detail page for them.
|
||||
if (detailPanel.isOpen()) detailPanel.close();
|
||||
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
|
||||
resetGrid();
|
||||
await runBrowse(started.request);
|
||||
}
|
||||
|
||||
async function loadNextPage(): Promise<void> {
|
||||
const started = beginNextPage(browseState);
|
||||
if (!started) return;
|
||||
browseState = started.state;
|
||||
setStatus(`Loading page ${started.request.page}…`);
|
||||
await runBrowse(started.request);
|
||||
}
|
||||
|
||||
/* ---------- source settings ---------- */
|
||||
|
||||
async function openSettings(): Promise<void> {
|
||||
const option = sourceSelect.selectedOptions[0];
|
||||
settingsTitle.textContent = option ? `${option.textContent} settings` : 'Source settings';
|
||||
settingsFields.replaceChildren();
|
||||
setView('settings');
|
||||
|
||||
// Settings belong to one extension, so there is nothing coherent to show for
|
||||
// "All sources".
|
||||
if (searchingAllSources()) {
|
||||
renderPreferencesUnavailable(
|
||||
settingsFields,
|
||||
'Pick a single source in the Source picker to edit its settings.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceId = sourceSelect.value;
|
||||
try {
|
||||
renderPreferences(settingsFields, await api.getPreferences(sourceId), (key, value) =>
|
||||
api.setPreference(sourceId, key, value),
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
setView('browse');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- wiring ---------- */
|
||||
|
||||
async function refreshSources(): Promise<void> {
|
||||
const snapshot = await api.getSnapshot();
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||
}
|
||||
|
||||
const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
|
||||
|
||||
browseTab.addEventListener('click', () => setView('browse'));
|
||||
|
||||
settingsTab.addEventListener('click', () => void openSettings());
|
||||
|
||||
extensionsTab.addEventListener('click', () => {
|
||||
setView('extensions');
|
||||
void extensions.refresh();
|
||||
});
|
||||
|
||||
searchForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
// Searching is a browse action, whichever tab it was typed from.
|
||||
setView('browse');
|
||||
void runSearch(searchInput.value.trim());
|
||||
});
|
||||
|
||||
sourceSelect.addEventListener('change', () => {
|
||||
void (async () => {
|
||||
const requestedSourceId = sourceSelect.value;
|
||||
try {
|
||||
await api.selectSource(requestedSourceId);
|
||||
selectedSourceId = requestedSourceId;
|
||||
// Settings belong to the source, so reload them rather than showing stale fields.
|
||||
if (currentView === 'settings') await openSettings();
|
||||
await runSearch(searchInput.value.trim());
|
||||
} catch (error) {
|
||||
if (selectedSourceId !== null) sourceSelect.value = selectedSourceId;
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
loadMoreButton.addEventListener('click', () => void loadNextPage());
|
||||
|
||||
api.onBridgeState(renderBridgeState);
|
||||
|
||||
void (async () => {
|
||||
renderBridgeState({ stage: 'idle', progress: null, message: null });
|
||||
try {
|
||||
const state = await api.ensureBridge();
|
||||
renderBridgeState(state);
|
||||
|
||||
const snapshot = await api.getSnapshot();
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||
|
||||
if (state.stage === 'ready' && snapshot.sources.length > 0) {
|
||||
searchInput.focus();
|
||||
await runSearch('');
|
||||
} else if (state.stage === 'ready') {
|
||||
setStatus(state.message ?? 'No extensions installed.', 'error');
|
||||
} else {
|
||||
setStatus(state.message ?? 'The extension bridge is not available.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
// Without this the window keeps the "starting" banner up forever, with the
|
||||
// search box disabled and nothing saying why.
|
||||
renderBridgeState({ stage: 'failed', progress: null, message: describe(error) });
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
beginBrowse,
|
||||
beginNextPage,
|
||||
capture,
|
||||
createBrowseState,
|
||||
failBrowse,
|
||||
finishBrowse,
|
||||
LatestRequest,
|
||||
safeUploadDate,
|
||||
soleBrowseRequest,
|
||||
takeUnseenEntries,
|
||||
} from './browse-state';
|
||||
import type { AnimeBrowserEntry } from '../types/anime-browser';
|
||||
|
||||
test('a new browse replaces results and starts from page one', () => {
|
||||
const started = beginBrowse(createBrowseState(), 'frieren');
|
||||
|
||||
assert.deepEqual(started.request, {
|
||||
id: 1,
|
||||
query: 'frieren',
|
||||
page: 1,
|
||||
append: false,
|
||||
});
|
||||
assert.equal(started.state.loading, true);
|
||||
});
|
||||
|
||||
test('the next page appends only when the current result has another page', () => {
|
||||
const first = beginBrowse(createBrowseState(), '');
|
||||
const ready = finishBrowse(first.state, first.request.id, true);
|
||||
const next = beginNextPage(ready);
|
||||
|
||||
assert.ok(next);
|
||||
assert.deepEqual(next.request, {
|
||||
id: 2,
|
||||
query: '',
|
||||
page: 2,
|
||||
append: true,
|
||||
});
|
||||
assert.equal(beginNextPage(next.state), null, 'cannot overlap page requests');
|
||||
assert.equal(beginNextPage(finishBrowse(next.state, next.request.id, false)), null);
|
||||
});
|
||||
|
||||
test('a stale page completion cannot change the active browse state', () => {
|
||||
const first = beginBrowse(createBrowseState(), 'old');
|
||||
const current = beginBrowse(first.state, 'new');
|
||||
|
||||
assert.equal(finishBrowse(current.state, first.request.id, true), current.state);
|
||||
});
|
||||
|
||||
test('stream updates are correlated only when one browse request is in flight', () => {
|
||||
const first = beginBrowse(createBrowseState(), 'old');
|
||||
const second = beginBrowse(first.state, 'new');
|
||||
const inFlight = new Map([
|
||||
[second.request.id, second.request],
|
||||
[first.request.id, first.request],
|
||||
]);
|
||||
|
||||
assert.equal(soleBrowseRequest(inFlight), null, 'start order is ambiguous while calls overlap');
|
||||
inFlight.delete(first.request.id);
|
||||
assert.equal(soleBrowseRequest(inFlight), second.request);
|
||||
});
|
||||
|
||||
test('a failed next page remains retryable', () => {
|
||||
const first = beginBrowse(createBrowseState(), '');
|
||||
const ready = finishBrowse(first.state, first.request.id, true);
|
||||
const next = beginNextPage(ready)!;
|
||||
const failed = failBrowse(next.state, next.request);
|
||||
const retry = beginNextPage(failed);
|
||||
|
||||
assert.ok(retry);
|
||||
assert.equal(retry.request.page, 2);
|
||||
});
|
||||
|
||||
test('latest request tokens invalidate closed and superseded details', () => {
|
||||
const requests = new LatestRequest();
|
||||
const first = requests.begin();
|
||||
const second = requests.begin();
|
||||
|
||||
assert.equal(requests.isCurrent(first), false);
|
||||
assert.equal(requests.isCurrent(second), true);
|
||||
requests.cancel();
|
||||
assert.equal(requests.isCurrent(second), false);
|
||||
});
|
||||
|
||||
test('safeUploadDate ignores malformed timestamps', () => {
|
||||
assert.equal(safeUploadDate(Date.UTC(2025, 3, 2)), '2025-04-02');
|
||||
assert.equal(safeUploadDate(Number.NaN), null);
|
||||
assert.equal(safeUploadDate(Number.POSITIVE_INFINITY), null);
|
||||
});
|
||||
|
||||
test('capture turns a rejected IPC operation into a displayable failure', async () => {
|
||||
const failure = new Error('bridge disconnected');
|
||||
const result = await capture(async () => Promise.reject(failure));
|
||||
|
||||
assert.deepEqual(result, { ok: false, error: failure });
|
||||
});
|
||||
|
||||
test('takeUnseenEntries deduplicates streamed and final-page entries', () => {
|
||||
const entry = (sourceId: string, url: string): AnimeBrowserEntry => ({
|
||||
sourceId,
|
||||
sourceName: sourceId,
|
||||
url,
|
||||
title: url,
|
||||
thumbnailUrl: null,
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
|
||||
assert.deepEqual(takeUnseenEntries([entry('one', '/a')], seen), [entry('one', '/a')]);
|
||||
assert.deepEqual(takeUnseenEntries([entry('one', '/a'), entry('two', '/a')], seen), [
|
||||
entry('two', '/a'),
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { AnimeBrowserEntry } from '../types/anime-browser';
|
||||
|
||||
export interface BrowseState {
|
||||
requestId: number;
|
||||
query: string;
|
||||
page: number;
|
||||
loading: boolean;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface BrowseRequest {
|
||||
id: number;
|
||||
query: string;
|
||||
page: number;
|
||||
append: boolean;
|
||||
}
|
||||
|
||||
export interface StartedBrowse {
|
||||
state: BrowseState;
|
||||
request: BrowseRequest;
|
||||
}
|
||||
|
||||
export function createBrowseState(): BrowseState {
|
||||
return { requestId: 0, query: '', page: 0, loading: false, hasNextPage: false };
|
||||
}
|
||||
|
||||
export function beginBrowse(state: BrowseState, query: string): StartedBrowse {
|
||||
const request: BrowseRequest = {
|
||||
id: state.requestId + 1,
|
||||
query,
|
||||
page: 1,
|
||||
append: false,
|
||||
};
|
||||
return {
|
||||
state: {
|
||||
requestId: request.id,
|
||||
query,
|
||||
page: request.page,
|
||||
loading: true,
|
||||
hasNextPage: false,
|
||||
},
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
export function beginNextPage(state: BrowseState): StartedBrowse | null {
|
||||
if (state.loading || !state.hasNextPage) return null;
|
||||
const request: BrowseRequest = {
|
||||
id: state.requestId + 1,
|
||||
query: state.query,
|
||||
page: state.page + 1,
|
||||
append: true,
|
||||
};
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
requestId: request.id,
|
||||
page: request.page,
|
||||
loading: true,
|
||||
hasNextPage: false,
|
||||
},
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
export function soleBrowseRequest(
|
||||
inFlight: ReadonlyMap<number, BrowseRequest>,
|
||||
): BrowseRequest | null {
|
||||
if (inFlight.size !== 1) return null;
|
||||
return inFlight.values().next().value ?? null;
|
||||
}
|
||||
|
||||
export function finishBrowse(
|
||||
state: BrowseState,
|
||||
requestId: number,
|
||||
hasNextPage: boolean,
|
||||
): BrowseState {
|
||||
if (requestId !== state.requestId) return state;
|
||||
return { ...state, loading: false, hasNextPage };
|
||||
}
|
||||
|
||||
export function failBrowse(state: BrowseState, request: BrowseRequest): BrowseState {
|
||||
if (request.id !== state.requestId) return state;
|
||||
return {
|
||||
...state,
|
||||
page: request.append ? request.page - 1 : request.page,
|
||||
loading: false,
|
||||
hasNextPage: request.append,
|
||||
};
|
||||
}
|
||||
|
||||
export class LatestRequest {
|
||||
private current = 0;
|
||||
|
||||
begin(): number {
|
||||
return ++this.current;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.current += 1;
|
||||
}
|
||||
|
||||
isCurrent(request: number): boolean {
|
||||
return request === this.current;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeUploadDate(uploadedAt: number): string | null {
|
||||
const date = new Date(uploadedAt);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
export type Captured<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
||||
|
||||
export async function capture<T>(operation: () => Promise<T>): Promise<Captured<T>> {
|
||||
try {
|
||||
return { ok: true, value: await operation() };
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
export function takeUnseenEntries(
|
||||
entries: AnimeBrowserEntry[],
|
||||
seen: Set<string>,
|
||||
): AnimeBrowserEntry[] {
|
||||
return entries.filter((entry) => {
|
||||
const key = `${entry.sourceId}\0${entry.url}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { capture, LatestRequest, safeUploadDate } from './browse-state';
|
||||
import { describe, el } from './dom';
|
||||
import type {
|
||||
AnimeBrowserAPI,
|
||||
AnimeBrowserEntry,
|
||||
AnimeBrowserEpisode,
|
||||
} from '../types/anime-browser';
|
||||
|
||||
interface DetailPanelOptions {
|
||||
api: AnimeBrowserAPI;
|
||||
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
|
||||
}
|
||||
|
||||
export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
|
||||
const results = el<HTMLElement>('results');
|
||||
const detail = el<HTMLElement>('detail');
|
||||
const detailBack = el<HTMLButtonElement>('detail-back');
|
||||
const detailCover = el<HTMLImageElement>('detail-cover');
|
||||
const detailTitle = el<HTMLHeadingElement>('detail-title');
|
||||
const detailChips = el<HTMLDivElement>('detail-chips');
|
||||
const detailDescription = el<HTMLParagraphElement>('detail-description');
|
||||
const episodes = el<HTMLOListElement>('episodes');
|
||||
const episodesCount = el<HTMLSpanElement>('episodes-count');
|
||||
|
||||
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
|
||||
let resultsScrollTop = 0;
|
||||
const requests = new LatestRequest();
|
||||
const playbacks = new LatestRequest();
|
||||
|
||||
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
|
||||
const value = episode.number ?? fallbackIndex;
|
||||
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
|
||||
}
|
||||
|
||||
async function playEpisode(
|
||||
button: HTMLButtonElement,
|
||||
episode: AnimeBrowserEpisode,
|
||||
): Promise<void> {
|
||||
const anime = selectedAnime;
|
||||
if (!anime) return;
|
||||
|
||||
// Only the newest click owns the button states and the status line; an
|
||||
// earlier episode resolving late must not overwrite them.
|
||||
const playback = playbacks.begin();
|
||||
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
|
||||
other.removeAttribute('data-state');
|
||||
}
|
||||
button.dataset.state = 'loading';
|
||||
setStatus(`Resolving ${episode.name}…`);
|
||||
|
||||
const attempt = await capture(() =>
|
||||
api.playEpisode({
|
||||
sourceId: anime.sourceId,
|
||||
animeUrl: anime.url,
|
||||
animeTitle: anime.title,
|
||||
episodeUrl: episode.url,
|
||||
episodeName: episode.name,
|
||||
episodeNumber: episode.number,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!playbacks.isCurrent(playback)) return;
|
||||
|
||||
if (!attempt.ok) {
|
||||
button.removeAttribute('data-state');
|
||||
setStatus(describe(attempt.error), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = attempt.value;
|
||||
if (result.ok) {
|
||||
button.dataset.state = 'playing';
|
||||
setStatus(
|
||||
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
|
||||
'ok',
|
||||
);
|
||||
} else {
|
||||
button.removeAttribute('data-state');
|
||||
setStatus(result.error ?? 'Could not play that episode.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
|
||||
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
|
||||
episodes.replaceChildren(
|
||||
...list.map((episode, index) => {
|
||||
const item = document.createElement('li');
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'cue';
|
||||
|
||||
const cueIndex = document.createElement('span');
|
||||
cueIndex.className = 'cue-index';
|
||||
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'cue-name';
|
||||
name.textContent = episode.name;
|
||||
if (episode.uploadedAt !== null) {
|
||||
const uploaded = safeUploadDate(episode.uploadedAt);
|
||||
if (uploaded) {
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'cue-sub';
|
||||
sub.textContent = uploaded;
|
||||
name.append(sub);
|
||||
}
|
||||
}
|
||||
|
||||
button.append(cueIndex, name);
|
||||
button.addEventListener('click', () => void playEpisode(button, episode));
|
||||
item.append(button);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function open(entry: AnimeBrowserEntry): Promise<void> {
|
||||
const request = requests.begin();
|
||||
selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
|
||||
resultsScrollTop = results.scrollTop;
|
||||
results.classList.add('hidden');
|
||||
detail.classList.remove('hidden');
|
||||
detail.scrollTop = 0;
|
||||
detailTitle.textContent = entry.title;
|
||||
detailDescription.textContent = 'Loading…';
|
||||
detailChips.replaceChildren();
|
||||
episodes.replaceChildren();
|
||||
episodesCount.textContent = '';
|
||||
detailCover.src = entry.thumbnailUrl ?? '';
|
||||
|
||||
try {
|
||||
const [details, episodeList] = await Promise.all([
|
||||
api.getDetails(entry.url, entry.sourceId),
|
||||
api.getEpisodes(entry.url, entry.sourceId),
|
||||
]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
|
||||
detailTitle.textContent = details.title;
|
||||
detailDescription.textContent = details.description ?? 'No description from this source.';
|
||||
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
|
||||
|
||||
const chips: HTMLSpanElement[] = [];
|
||||
const source = document.createElement('span');
|
||||
source.className = 'chip source';
|
||||
source.textContent = entry.sourceName;
|
||||
chips.push(source);
|
||||
if (details.status !== 'unknown') {
|
||||
const status = document.createElement('span');
|
||||
status.className = 'chip status';
|
||||
status.textContent = details.status.replace(/-/g, ' ');
|
||||
chips.push(status);
|
||||
}
|
||||
for (const genre of details.genres.slice(0, 6)) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.textContent = genre;
|
||||
chips.push(chip);
|
||||
}
|
||||
detailChips.replaceChildren(...chips);
|
||||
|
||||
renderEpisodes(episodeList);
|
||||
setStatus(`${details.title} · ${episodeList.length} episodes`);
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
detailDescription.textContent = '';
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
requests.cancel();
|
||||
playbacks.cancel();
|
||||
detail.classList.add('hidden');
|
||||
results.classList.remove('hidden');
|
||||
results.scrollTop = resultsScrollTop;
|
||||
selectedAnime = null;
|
||||
}
|
||||
|
||||
detailBack.addEventListener('click', close);
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !detail.classList.contains('hidden')) close();
|
||||
});
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
isOpen: (): boolean => !detail.classList.contains('hidden'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/* ---------- detail page ---------- */
|
||||
|
||||
/*
|
||||
* The detail view is a page, not a sidebar: it takes over the whole content
|
||||
* region while the results grid waits, hidden, behind the Back button.
|
||||
*/
|
||||
.detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: var(--panel);
|
||||
overflow-y: auto;
|
||||
padding: 20px clamp(20px, 5vw, 56px) 32px;
|
||||
animation: detail-in 0.28s ease both;
|
||||
}
|
||||
|
||||
@keyframes detail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
gap: clamp(18px, 3vw, 36px);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
width: clamp(140px, 18vw, 232px);
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--ctp-crust);
|
||||
box-shadow: 0 18px 40px -24px var(--shadow);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(19px, 2.4vw, 27px);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.detail-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chip.status {
|
||||
border-color: rgba(166, 218, 149, 0.4);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* Which extension answered — the one chip that is always present. */
|
||||
.chip.source {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
max-width: 72ch;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.episodes-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.episodes-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.episodes-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/*
|
||||
* The cue rail: episodes read as subtitle cues on a timeline, because that is
|
||||
* what they are about to become. The rail is the spine, the index is the cue
|
||||
* number, and the title is the cue text.
|
||||
*/
|
||||
|
||||
.cue-rail {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cue-rail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 48px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.cue {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr;
|
||||
gap: 26px;
|
||||
align-items: baseline;
|
||||
width: 100%;
|
||||
padding: 9px 10px 9px 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease;
|
||||
}
|
||||
|
||||
.cue::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 43px;
|
||||
top: 15px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--line);
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
border-color 0.14s ease;
|
||||
}
|
||||
|
||||
.cue:hover,
|
||||
.cue:focus-visible {
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.cue:hover::after,
|
||||
.cue:focus-visible::after {
|
||||
background: var(--mine);
|
||||
border-color: var(--mine);
|
||||
}
|
||||
|
||||
.cue-index {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cue-name {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cue-sub {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cue[data-state='loading'] {
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.cue[data-state='loading']::after {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cue[data-state='playing']::after {
|
||||
background: var(--mine);
|
||||
border-color: var(--mine);
|
||||
}
|
||||
|
||||
/* ---------- status bar ---------- */
|
||||
|
||||
.statusbar {
|
||||
flex: none;
|
||||
padding: 7px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--ctp-mantle);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.statusbar[data-tone='error'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.statusbar[data-tone='ok'] {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* ---------- scrollbars ---------- */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Small helpers shared by the anime browser's panels. */
|
||||
|
||||
export function el<T extends HTMLElement>(id: string): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) throw new Error(`Missing element #${id}`);
|
||||
return node as T;
|
||||
}
|
||||
|
||||
export function describe(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Electron wraps handler errors; keep only the useful tail.
|
||||
return message.replace(/^Error invoking remote method '[^']+':\s*/, '').replace(/^Error:\s*/, '');
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { describe, el } from './dom';
|
||||
import { describeInstalled } from './format';
|
||||
import {
|
||||
collectLanguages,
|
||||
filterByLanguage,
|
||||
languageLabel,
|
||||
pruneSelection,
|
||||
toggleLanguage,
|
||||
} from './language-filter';
|
||||
import type {
|
||||
AnimeBrowserAPI,
|
||||
AvailableExtension,
|
||||
InstalledExtensionView,
|
||||
} from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* The Extensions tab: what is installed, which repositories feed it, and what
|
||||
* those repositories still offer.
|
||||
*
|
||||
* Installed extensions get their own section at the top, listed from the
|
||||
* extensions directory rather than from a repository catalogue — an APK dropped
|
||||
* in by hand, or one whose repository has since been removed, is still
|
||||
* installed and has to stay removable.
|
||||
*/
|
||||
|
||||
export interface ExtensionsPanelOptions {
|
||||
api: AnimeBrowserAPI;
|
||||
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
|
||||
/** Called after an install or removal, so the source picker keeps up. */
|
||||
onSourcesChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface RowAction {
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface RowOptions {
|
||||
name: string;
|
||||
sub: string;
|
||||
tags?: Array<{ text: string; className: string }>;
|
||||
actions?: RowAction[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
function extensionRow(options: RowOptions): HTMLDivElement {
|
||||
const row = document.createElement('div');
|
||||
row.className = options.isError ? 'ext-row is-error' : 'ext-row';
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'ext-main';
|
||||
const name = document.createElement('div');
|
||||
name.className = 'ext-name';
|
||||
name.textContent = options.name;
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'ext-sub';
|
||||
sub.textContent = options.sub;
|
||||
main.append(name, sub);
|
||||
row.append(main);
|
||||
|
||||
for (const tag of options.tags ?? []) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = `ext-tag ${tag.className}`;
|
||||
chip.textContent = tag.text;
|
||||
row.append(chip);
|
||||
}
|
||||
|
||||
for (const action of options.actions ?? []) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = action.primary ? 'primary-button' : 'ghost-button';
|
||||
button.textContent = action.label;
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
void Promise.resolve(action.onClick()).finally(() => {
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
row.append(button);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function emptyNote(text: string): HTMLParagraphElement {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'ext-empty';
|
||||
empty.textContent = text;
|
||||
return empty;
|
||||
}
|
||||
|
||||
export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
const { api, setStatus, onSourcesChanged } = options;
|
||||
|
||||
const extensionsDirLabel = el<HTMLSpanElement>('extensions-dir');
|
||||
const installedList = el<HTMLDivElement>('installed-list');
|
||||
const installedCount = el<HTMLSpanElement>('installed-count');
|
||||
const availableList = el<HTMLDivElement>('extensions-list');
|
||||
const availableCount = el<HTMLSpanElement>('available-count');
|
||||
const langFilter = el<HTMLDivElement>('lang-filter');
|
||||
const repoInput = el<HTMLInputElement>('repo-input');
|
||||
const repoAddButton = el<HTMLButtonElement>('repo-add');
|
||||
const repoList = el<HTMLDivElement>('repo-list');
|
||||
|
||||
// What the last refresh found, kept so toggling a language chip re-renders
|
||||
// the list without re-fetching every repository index.
|
||||
let installable: AvailableExtension[] = [];
|
||||
let repoFailures: Array<{ name: string; error: string }> = [];
|
||||
let hasRepos = false;
|
||||
/** Selected language codes; empty means "All". */
|
||||
let selectedLangs = new Set<string>();
|
||||
|
||||
async function afterChange(extensionName: string, verb: string): Promise<void> {
|
||||
await refresh();
|
||||
await onSourcesChanged();
|
||||
setStatus(`${extensionName} ${verb}`, 'ok');
|
||||
}
|
||||
|
||||
function renderInstalled(
|
||||
installed: InstalledExtensionView[],
|
||||
offeredPkgs: Set<string>,
|
||||
extensionsDir: string,
|
||||
): void {
|
||||
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
|
||||
|
||||
if (installed.length === 0) {
|
||||
installedList.replaceChildren(
|
||||
emptyNote(
|
||||
`Nothing installed yet. Add a repository below, or drop .apk files in ${extensionsDir}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
installedList.replaceChildren(
|
||||
...installed.map((view) => {
|
||||
const actions: RowAction[] = [];
|
||||
// Only offer an update for an extension a configured repository still
|
||||
// carries; reinstalling overwrites the APK in place.
|
||||
if (offeredPkgs.has(view.pkg)) {
|
||||
actions.push({
|
||||
label: 'Update',
|
||||
onClick: async () => {
|
||||
setStatus(`Updating ${view.name}…`);
|
||||
try {
|
||||
await api.installExtension(view.pkg);
|
||||
await afterChange(view.name, 'updated');
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
label: 'Remove',
|
||||
onClick: async () => {
|
||||
setStatus(`Removing ${view.name}…`);
|
||||
try {
|
||||
await api.removeExtension(view.pkg);
|
||||
await afterChange(view.name, 'removed');
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return extensionRow({
|
||||
name: view.name,
|
||||
sub: view.error ?? describeInstalled(view),
|
||||
isError: view.error !== null,
|
||||
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
|
||||
actions,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function renderRepos(repos: string[]): void {
|
||||
repoList.replaceChildren(
|
||||
...repos.map((repoUrl) =>
|
||||
extensionRow({
|
||||
name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''),
|
||||
sub: repoUrl,
|
||||
actions: [
|
||||
{
|
||||
label: 'Remove',
|
||||
onClick: async () => {
|
||||
setStatus('Removing repository…');
|
||||
try {
|
||||
await api.removeRepo(repoUrl);
|
||||
await refresh();
|
||||
setStatus('Repository removed', 'ok');
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function languageChip(label: string, active: boolean, onClick: () => void): HTMLButtonElement {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = active ? 'lang-chip is-active' : 'lang-chip';
|
||||
chip.textContent = label;
|
||||
chip.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
chip.addEventListener('click', onClick);
|
||||
return chip;
|
||||
}
|
||||
|
||||
function renderLanguageFilter(languages: string[]): void {
|
||||
// One language, or none: there is nothing to choose between.
|
||||
if (languages.length < 2) {
|
||||
langFilter.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
langFilter.replaceChildren(
|
||||
// "All" is the empty selection, so picking a language always replaces it
|
||||
// rather than sitting alongside it.
|
||||
languageChip('All', selectedLangs.size === 0, () => {
|
||||
if (selectedLangs.size === 0) return;
|
||||
selectedLangs = new Set();
|
||||
renderAvailable();
|
||||
}),
|
||||
...languages.map((code) =>
|
||||
languageChip(languageLabel(code), selectedLangs.has(code), () => {
|
||||
selectedLangs = toggleLanguage(selectedLangs, code);
|
||||
renderAvailable();
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderAvailable(): void {
|
||||
const languages = collectLanguages(installable);
|
||||
selectedLangs = pruneSelection(selectedLangs, languages);
|
||||
renderLanguageFilter(languages);
|
||||
|
||||
const shown = filterByLanguage(installable, selectedLangs);
|
||||
availableCount.textContent =
|
||||
shown.length === installable.length
|
||||
? installable.length === 0
|
||||
? ''
|
||||
: String(installable.length)
|
||||
: `${shown.length} of ${installable.length}`;
|
||||
|
||||
const rows: HTMLElement[] = repoFailures.map((failure) =>
|
||||
extensionRow({ name: failure.name, sub: failure.error, isError: true }),
|
||||
);
|
||||
|
||||
for (const extension of shown) {
|
||||
rows.push(
|
||||
extensionRow({
|
||||
name: extension.name,
|
||||
sub: `${languageLabel(extension.lang)} · v${extension.version}`,
|
||||
tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
|
||||
actions: [
|
||||
{
|
||||
label: 'Install',
|
||||
primary: true,
|
||||
onClick: async () => {
|
||||
setStatus(`Installing ${extension.name}…`);
|
||||
try {
|
||||
await api.installExtension(extension.pkg);
|
||||
await afterChange(extension.name, 'installed');
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
rows.push(
|
||||
emptyNote(
|
||||
selectedLangs.size > 0
|
||||
? 'No available extension matches the selected languages.'
|
||||
: hasRepos
|
||||
? 'Every extension the configured repositories offer is already installed.'
|
||||
: 'No repository configured, so there is nothing to install from.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
availableList.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const snapshot = await api.getSnapshot();
|
||||
extensionsDirLabel.textContent = snapshot.extensionsDir;
|
||||
renderRepos(snapshot.repos);
|
||||
|
||||
repoFailures = [];
|
||||
let available;
|
||||
try {
|
||||
available = await api.listAvailableExtensions();
|
||||
} catch (error) {
|
||||
repoFailures.push({ name: 'Repository error', error: describe(error) });
|
||||
available = { extensions: [], failures: [] };
|
||||
}
|
||||
for (const failure of available.failures) {
|
||||
repoFailures.push({ name: failure.repoUrl, error: failure.error });
|
||||
}
|
||||
|
||||
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
|
||||
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
|
||||
// Installed extensions have their own section; leaving them here too would
|
||||
// list every one of them twice.
|
||||
installable = available.extensions.filter((extension) => !extension.installed);
|
||||
hasRepos = snapshot.repos.length > 0;
|
||||
renderAvailable();
|
||||
}
|
||||
|
||||
repoAddButton.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
const url = repoInput.value.trim();
|
||||
if (url.length === 0) return;
|
||||
try {
|
||||
await api.addRepo(url);
|
||||
repoInput.value = '';
|
||||
setStatus('Repository added');
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
repoInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
repoAddButton.click();
|
||||
}
|
||||
});
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describeInstalled, sourceOptionLabel, summarizeSearch } from './format';
|
||||
import type { AnimeBrowserSearchResult } from '../types/anime-browser';
|
||||
|
||||
const result = (
|
||||
entryCount: number,
|
||||
failures: AnimeBrowserSearchResult['failures'] = [],
|
||||
): AnimeBrowserSearchResult => ({
|
||||
entries: Array.from({ length: entryCount }, (_unused, index) => ({
|
||||
url: `/a/${index}`,
|
||||
title: `Anime ${index}`,
|
||||
thumbnailUrl: null,
|
||||
sourceId: 's',
|
||||
sourceName: 'Source',
|
||||
})),
|
||||
hasNextPage: false,
|
||||
failures,
|
||||
});
|
||||
|
||||
test('sourceOptionLabel omits the language for an all-language source', () => {
|
||||
assert.equal(sourceOptionLabel({ id: '1', name: 'Nyaa', lang: 'ja', pkg: 'p' }), 'Nyaa (ja)');
|
||||
assert.equal(sourceOptionLabel({ id: '2', name: 'Jellyfin', lang: 'all', pkg: 'p' }), 'Jellyfin');
|
||||
});
|
||||
|
||||
test('summarizeSearch counts results and singularizes one', () => {
|
||||
assert.equal(summarizeSearch(result(4)), '4 results');
|
||||
assert.equal(summarizeSearch(result(1)), '1 result');
|
||||
assert.equal(summarizeSearch(result(0)), '0 results');
|
||||
});
|
||||
|
||||
test('summarizeSearch names the sources that failed alongside the ones that answered', () => {
|
||||
const summary = summarizeSearch(
|
||||
result(6, [
|
||||
{ sourceId: 'a', sourceName: 'Alpha', error: 'login required' },
|
||||
{ sourceId: 'b', sourceName: 'Beta', error: 'timed out' },
|
||||
]),
|
||||
);
|
||||
assert.equal(summary, '6 results · 2 unavailable: Alpha, Beta');
|
||||
});
|
||||
|
||||
test('describeInstalled reports sources and languages when the extension loaded', () => {
|
||||
assert.equal(
|
||||
describeInstalled({
|
||||
pkg: 'multi',
|
||||
name: 'One, Two',
|
||||
langs: ['en', 'ja'],
|
||||
sourceCount: 2,
|
||||
error: null,
|
||||
}),
|
||||
'multi · 2 sources · en, ja',
|
||||
);
|
||||
});
|
||||
|
||||
test('describeInstalled falls back to the package alone when nothing loaded', () => {
|
||||
assert.equal(
|
||||
describeInstalled({ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'boom' }),
|
||||
'broken',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
AnimeBrowserSearchResult,
|
||||
AnimeBrowserSource,
|
||||
InstalledExtensionView,
|
||||
} from '../types/anime-browser';
|
||||
|
||||
/** Display strings for the anime browser, kept separate from the DOM. */
|
||||
|
||||
/** `Nyaa (ja)`, or just the name for a source that serves every language. */
|
||||
export function sourceOptionLabel(source: AnimeBrowserSource): string {
|
||||
return source.lang === 'all' ? source.name : `${source.name} (${source.lang})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status line after a search.
|
||||
*
|
||||
* An all-sources search can half-succeed, so the sources that failed are named
|
||||
* rather than folded into a count the user cannot act on.
|
||||
*/
|
||||
export function summarizeSearch(result: AnimeBrowserSearchResult): string {
|
||||
const count = `${result.entries.length} result${result.entries.length === 1 ? '' : 's'}`;
|
||||
if (result.failures.length === 0) return count;
|
||||
const names = result.failures.map((failure) => failure.sourceName).join(', ');
|
||||
return `${count} · ${result.failures.length} unavailable: ${names}`;
|
||||
}
|
||||
|
||||
/** `pkg · 3 sources · en, ja`, trimmed to what the extension actually reported. */
|
||||
export function describeInstalled(view: InstalledExtensionView): string {
|
||||
const parts = [view.pkg];
|
||||
if (view.sourceCount > 0) {
|
||||
parts.push(`${view.sourceCount} source${view.sourceCount === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (view.langs.length > 0) parts.push(view.langs.join(', '));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:* https:; object-src 'none'; base-uri 'self';"
|
||||
/>
|
||||
<title>SubMiner Anime</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<link rel="stylesheet" href="./detail.css" />
|
||||
<link rel="stylesheet" href="./panels.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Anime</div>
|
||||
</div>
|
||||
|
||||
<form class="search-form" id="search-form" role="search">
|
||||
<input
|
||||
class="text-input search-input"
|
||||
id="search-input"
|
||||
type="search"
|
||||
placeholder="Search anime"
|
||||
autocomplete="off"
|
||||
aria-label="Search anime"
|
||||
/>
|
||||
<button class="primary-button" id="search-button" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<label class="source-picker">
|
||||
<span class="source-label">Source</span>
|
||||
<select class="text-input" id="source-select" aria-label="Extension source"></select>
|
||||
</label>
|
||||
|
||||
<nav class="tabs" role="tablist" aria-label="View">
|
||||
<button
|
||||
class="tab"
|
||||
id="tab-browse"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="layout"
|
||||
aria-selected="true"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
id="tab-extensions"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="extensions"
|
||||
aria-selected="false"
|
||||
>
|
||||
Extensions
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
id="tab-settings"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="settings"
|
||||
aria-selected="false"
|
||||
>
|
||||
Source settings
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="bridge-banner hidden" id="bridge-banner" role="status" aria-live="polite">
|
||||
<span class="bridge-dot" id="bridge-dot"></span>
|
||||
<span class="bridge-message" id="bridge-message"></span>
|
||||
<span class="bridge-meter hidden" id="bridge-meter"><i id="bridge-meter-fill"></i></span>
|
||||
</div>
|
||||
|
||||
<section class="settings hidden" id="settings" role="tabpanel" aria-labelledby="tab-settings">
|
||||
<div class="settings-head">
|
||||
<h2 class="settings-title" id="settings-title">Source settings</h2>
|
||||
<span class="settings-note">Saved as you change them.</span>
|
||||
</div>
|
||||
<div class="settings-fields" id="settings-fields"></div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="settings hidden"
|
||||
id="extensions"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-extensions"
|
||||
>
|
||||
<div class="settings-head">
|
||||
<h2 class="settings-title">Extensions</h2>
|
||||
<span class="settings-note" id="extensions-dir"></span>
|
||||
</div>
|
||||
|
||||
<h3 class="ext-group-title">
|
||||
Installed <span class="ext-group-count" id="installed-count"></span>
|
||||
</h3>
|
||||
<div class="ext-list" id="installed-list" aria-label="Installed extensions"></div>
|
||||
|
||||
<h3 class="ext-group-title">Repositories</h3>
|
||||
<div class="repo-add">
|
||||
<input
|
||||
class="text-input"
|
||||
id="repo-input"
|
||||
type="url"
|
||||
placeholder="https://example.org/repo/index.min.json"
|
||||
aria-label="Repository index URL"
|
||||
/>
|
||||
<button class="primary-button" id="repo-add" type="button">Add repository</button>
|
||||
</div>
|
||||
<p class="repo-hint" id="repo-hint">
|
||||
SubMiner ships no repositories. Add any https URL to a repository's <code>.json</code> index
|
||||
to install extensions from it, or drop <code>.apk</code> files in the extensions directory.
|
||||
</p>
|
||||
|
||||
<div class="ext-list" id="repo-list" aria-label="Repositories"></div>
|
||||
|
||||
<h3 class="ext-group-title">
|
||||
Available <span class="ext-group-count" id="available-count"></span>
|
||||
</h3>
|
||||
<div class="lang-filter" id="lang-filter" role="group" aria-label="Filter by language"></div>
|
||||
<div class="ext-list" id="extensions-list" aria-label="Available extensions"></div>
|
||||
</section>
|
||||
|
||||
<main class="layout" id="layout" role="tabpanel" aria-labelledby="tab-browse">
|
||||
<section class="results" id="results" aria-label="Results">
|
||||
<div class="grid" id="grid"></div>
|
||||
<p class="empty hidden" id="grid-empty"></p>
|
||||
<button class="ghost-button load-more hidden" id="load-more" type="button">
|
||||
Load more
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="detail hidden" id="detail" aria-label="Details">
|
||||
<div class="detail-body">
|
||||
<button class="ghost-button detail-back" id="detail-back" type="button">
|
||||
← Back to results
|
||||
</button>
|
||||
|
||||
<div class="detail-head">
|
||||
<img class="detail-cover" id="detail-cover" alt="" />
|
||||
<div class="detail-meta">
|
||||
<h2 class="detail-title" id="detail-title"></h2>
|
||||
<div class="detail-chips" id="detail-chips"></div>
|
||||
<p class="detail-description" id="detail-description"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="episodes-head">
|
||||
<h3 class="episodes-title">Episodes</h3>
|
||||
<span class="episodes-count" id="episodes-count"></span>
|
||||
</div>
|
||||
<ol class="cue-rail" id="episodes"></ol>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span id="status-message"></span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="./animeui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
collectLanguages,
|
||||
filterByLanguage,
|
||||
languageLabel,
|
||||
pruneSelection,
|
||||
toggleLanguage,
|
||||
} from './language-filter';
|
||||
|
||||
const ext = (lang: string, name = lang) => ({ lang, name });
|
||||
|
||||
test('languageLabel names known tags and falls back to the raw code', () => {
|
||||
assert.equal(languageLabel('ja'), 'Japanese');
|
||||
assert.equal(languageLabel('all'), 'Multi-language');
|
||||
assert.equal(languageLabel('zzzz'), 'ZZZZ');
|
||||
});
|
||||
|
||||
test('collectLanguages dedupes, puts multi-language first, then sorts by name', () => {
|
||||
// English, German, Japanese — display names, not codes.
|
||||
assert.deepEqual(collectLanguages([ext('ja'), ext('de'), ext('ja'), ext('all'), ext('en')]), [
|
||||
'all',
|
||||
'en',
|
||||
'de',
|
||||
'ja',
|
||||
]);
|
||||
});
|
||||
|
||||
test('toggleLanguage adds, removes, and empties back to All', () => {
|
||||
const one = toggleLanguage(new Set(), 'ja');
|
||||
assert.deepEqual([...one], ['ja']);
|
||||
const two = toggleLanguage(one, 'en');
|
||||
assert.deepEqual([...two].sort(), ['en', 'ja']);
|
||||
assert.deepEqual([...toggleLanguage(two, 'ja')], ['en']);
|
||||
assert.equal(toggleLanguage(one, 'ja').size, 0);
|
||||
});
|
||||
|
||||
test('pruneSelection drops languages no repository offers any more', () => {
|
||||
assert.deepEqual([...pruneSelection(new Set(['ja', 'de']), ['ja', 'en'])], ['ja']);
|
||||
});
|
||||
|
||||
test('filterByLanguage keeps everything when nothing is selected', () => {
|
||||
const extensions = [ext('ja'), ext('en'), ext('all')];
|
||||
assert.deepEqual(filterByLanguage(extensions, new Set()), extensions);
|
||||
assert.deepEqual(
|
||||
filterByLanguage(extensions, new Set(['ja', 'all'])).map((extension) => extension.name),
|
||||
['ja', 'all'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The language filter for the available-extensions list.
|
||||
*
|
||||
* A repository index carries every language it knows about, which is far more
|
||||
* than any one user reads. The selection is a set of language codes; the empty
|
||||
* set means "All", so picking a language always replaces "All" rather than
|
||||
* sitting alongside it.
|
||||
*/
|
||||
|
||||
/** The code a repository uses for an extension whose sources span languages. */
|
||||
export const MULTI_LANG = 'all';
|
||||
|
||||
let displayNames: Intl.DisplayNames | null | undefined;
|
||||
|
||||
function languageDisplayNames(): Intl.DisplayNames | null {
|
||||
if (displayNames === undefined) {
|
||||
try {
|
||||
displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
|
||||
} catch {
|
||||
displayNames = null;
|
||||
}
|
||||
}
|
||||
return displayNames;
|
||||
}
|
||||
|
||||
/** `ja` → `Japanese`, `pt-BR` → `Brazilian Portuguese`, `all` → `Multi-language`. */
|
||||
export function languageLabel(code: string): string {
|
||||
if (code === MULTI_LANG) return 'Multi-language';
|
||||
try {
|
||||
const name = languageDisplayNames()?.of(code);
|
||||
// Intl echoes the input back when it knows no name for the tag.
|
||||
if (name && name !== code) return name;
|
||||
} catch {
|
||||
// An invalid tag throws; fall through to the raw code.
|
||||
}
|
||||
return code.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The languages offered, ordered for the chip row: multi-language first
|
||||
* because it is the odd one out, then alphabetically by display name.
|
||||
*/
|
||||
export function collectLanguages(extensions: ReadonlyArray<{ lang: string }>): string[] {
|
||||
const codes = [...new Set(extensions.map((extension) => extension.lang))];
|
||||
return codes.sort((a, b) => {
|
||||
if (a === MULTI_LANG || b === MULTI_LANG) return a === MULTI_LANG ? -1 : 1;
|
||||
return languageLabel(a).localeCompare(languageLabel(b));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection after clicking a language chip. Toggling the last selected
|
||||
* language off leaves the empty set, which is "All" again.
|
||||
*/
|
||||
export function toggleLanguage(selected: ReadonlySet<string>, code: string): Set<string> {
|
||||
const next = new Set(selected);
|
||||
if (!next.delete(code)) next.add(code);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the codes no repository offers any more, so a language that disappears
|
||||
* when a repository is removed does not keep filtering the list invisibly.
|
||||
*/
|
||||
export function pruneSelection(
|
||||
selected: ReadonlySet<string>,
|
||||
available: ReadonlyArray<string>,
|
||||
): Set<string> {
|
||||
const offered = new Set(available);
|
||||
return new Set([...selected].filter((code) => offered.has(code)));
|
||||
}
|
||||
|
||||
/** The extensions to show; an empty selection shows everything. */
|
||||
export function filterByLanguage<T extends { lang: string }>(
|
||||
extensions: ReadonlyArray<T>,
|
||||
selected: ReadonlySet<string>,
|
||||
): T[] {
|
||||
if (selected.size === 0) return [...extensions];
|
||||
return extensions.filter((extension) => selected.has(extension.lang));
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/* ---------- source settings ---------- */
|
||||
|
||||
.settings {
|
||||
/* A tab panel owns the whole content region; only one is ever visible. */
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 18px;
|
||||
background: var(--panel-elevated);
|
||||
}
|
||||
|
||||
.settings-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.settings-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-summary {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-row .text-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-multi {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-state {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.field-state[data-tone='ok'] {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.field-state[data-tone='error'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ---------- extensions panel ---------- */
|
||||
|
||||
.repo-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.repo-add .text-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.repo-hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
max-width: 78ch;
|
||||
}
|
||||
|
||||
.repo-hint code {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ext-group-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* A rule between the groups, but not above the first one. */
|
||||
.ext-list + .ext-group-title {
|
||||
margin-top: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ext-group-count {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ext-group-count:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lang-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lang-filter:not(:empty) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.lang-chip {
|
||||
padding: 4px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--ctp-crust);
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.lang-chip:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.lang-chip.is-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lang-chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ext-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ext-list:not(:empty) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ext-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.ext-row.is-error {
|
||||
border-color: rgba(237, 135, 150, 0.45);
|
||||
}
|
||||
|
||||
.ext-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ext-name {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ext-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ext-row.is-error .ext-sub {
|
||||
color: var(--danger);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ext-tag {
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.ext-tag.installed {
|
||||
border-color: rgba(166, 218, 149, 0.4);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.ext-tag.nsfw {
|
||||
border-color: rgba(237, 135, 150, 0.4);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ext-row .ghost-button,
|
||||
.ext-row .primary-button {
|
||||
flex: none;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ext-empty {
|
||||
padding: 14px 2px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe } from './dom';
|
||||
import type { SourcePreferenceView } from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* Renders one extension's settings schema as form fields.
|
||||
*
|
||||
* The extension owns the schema, so a commit hands back a refreshed one and the
|
||||
* whole panel re-renders from it — the Jellyfin source fills in its library
|
||||
* picker only after a successful login, and that has to show up.
|
||||
*/
|
||||
|
||||
export type PreferenceCommit = (
|
||||
key: string,
|
||||
value: string | string[] | boolean,
|
||||
) => Promise<SourcePreferenceView[]>;
|
||||
|
||||
/** Masked in the UI so a shoulder-surfer cannot read a stored password. */
|
||||
function isSecretKey(view: SourcePreferenceView): boolean {
|
||||
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of a schema, ignoring the values.
|
||||
*
|
||||
* A commit hands back the whole schema, but re-rendering on every save would
|
||||
* throw away the "Saved" note, move focus off the control that was just
|
||||
* committed, and rebuild a `multi` group out from under a second toggle that is
|
||||
* still in flight. Only a structural change is worth a rebuild — which is
|
||||
* exactly the case that matters, Jellyfin filling in its library picker after a
|
||||
* successful login.
|
||||
*/
|
||||
function schemaShape(views: SourcePreferenceView[]): string {
|
||||
return JSON.stringify(
|
||||
views.map((view) => [
|
||||
view.key,
|
||||
view.kind,
|
||||
view.title,
|
||||
view.summary,
|
||||
view.entries,
|
||||
view.entryValues,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function renderPreferenceField(
|
||||
container: HTMLElement,
|
||||
view: SourcePreferenceView,
|
||||
commit: PreferenceCommit,
|
||||
): HTMLElement {
|
||||
const field = document.createElement('div');
|
||||
field.className = 'field';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'field-label';
|
||||
label.textContent = view.title;
|
||||
field.append(label);
|
||||
|
||||
if (view.summary) {
|
||||
const summary = document.createElement('span');
|
||||
summary.className = 'field-summary';
|
||||
summary.textContent = view.summary;
|
||||
field.append(summary);
|
||||
}
|
||||
|
||||
const state = document.createElement('span');
|
||||
state.className = 'field-state';
|
||||
|
||||
const save = async (value: string | string[] | boolean): Promise<void> => {
|
||||
state.removeAttribute('data-tone');
|
||||
state.textContent = 'Saving…';
|
||||
try {
|
||||
// Commits are chained so a second toggle cannot land before the first
|
||||
// one's round trip finishes and overwrite it with a stale array.
|
||||
const refreshed = await enqueueCommit(container, () => commit(view.key, value));
|
||||
state.dataset.tone = 'ok';
|
||||
state.textContent = 'Saved';
|
||||
if (schemaShape(refreshed) !== renderedShapes.get(container)) {
|
||||
renderPreferences(container, refreshed, commit);
|
||||
}
|
||||
} catch (error) {
|
||||
state.dataset.tone = 'error';
|
||||
state.textContent = describe(error);
|
||||
}
|
||||
};
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'field-row';
|
||||
|
||||
if (view.kind === 'switch') {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'field-check';
|
||||
const box = document.createElement('input');
|
||||
box.type = 'checkbox';
|
||||
box.checked = view.value === true;
|
||||
box.addEventListener('change', () => void save(box.checked));
|
||||
wrapper.append(box, document.createTextNode('Enabled'));
|
||||
row.append(wrapper);
|
||||
} else if (view.kind === 'list') {
|
||||
const select = document.createElement('select');
|
||||
select.className = 'text-input';
|
||||
for (const [index, entryValue] of view.entryValues.entries()) {
|
||||
const option = document.createElement('option');
|
||||
option.value = entryValue;
|
||||
option.textContent = view.entries[index] ?? entryValue;
|
||||
option.selected = entryValue === view.value;
|
||||
select.append(option);
|
||||
}
|
||||
if (view.entryValues.length === 0) {
|
||||
select.disabled = true;
|
||||
const option = document.createElement('option');
|
||||
option.textContent = 'Nothing to choose yet';
|
||||
select.append(option);
|
||||
}
|
||||
select.addEventListener('change', () => void save(select.value));
|
||||
row.append(select);
|
||||
} else if (view.kind === 'multi') {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'field-multi';
|
||||
const selected = new Set(Array.isArray(view.value) ? view.value : []);
|
||||
for (const [index, entryValue] of view.entryValues.entries()) {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'field-check';
|
||||
const box = document.createElement('input');
|
||||
box.type = 'checkbox';
|
||||
box.checked = selected.has(entryValue);
|
||||
box.addEventListener('change', () => {
|
||||
if (box.checked) selected.add(entryValue);
|
||||
else selected.delete(entryValue);
|
||||
void save([...selected]);
|
||||
});
|
||||
wrapper.append(box, document.createTextNode(view.entries[index] ?? entryValue));
|
||||
group.append(wrapper);
|
||||
}
|
||||
row.append(group);
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.className = 'text-input';
|
||||
input.type = isSecretKey(view) ? 'password' : 'text';
|
||||
input.value = typeof view.value === 'string' ? view.value : '';
|
||||
// Commit on blur/Enter rather than per keystroke; each save round-trips
|
||||
// to the extension and may trigger a login.
|
||||
input.addEventListener('change', () => void save(input.value));
|
||||
row.append(input);
|
||||
}
|
||||
|
||||
field.append(row, state);
|
||||
return field;
|
||||
}
|
||||
|
||||
/** Shape currently on screen, per container, so a save can skip a no-op rebuild. */
|
||||
const renderedShapes = new WeakMap<HTMLElement, string>();
|
||||
|
||||
/** Serializes commits per container; see the comment in `save`. */
|
||||
const commitQueues = new WeakMap<HTMLElement, Promise<unknown>>();
|
||||
|
||||
function enqueueCommit(
|
||||
container: HTMLElement,
|
||||
run: () => Promise<SourcePreferenceView[]>,
|
||||
): Promise<SourcePreferenceView[]> {
|
||||
const previous = commitQueues.get(container) ?? Promise.resolve();
|
||||
const result = previous.then(run, run);
|
||||
// Swallow here only so one failed commit does not wedge the queue; the
|
||||
// caller still sees the rejection.
|
||||
commitQueues.set(
|
||||
container,
|
||||
result.catch(() => undefined),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderPreferences(
|
||||
container: HTMLElement,
|
||||
views: SourcePreferenceView[],
|
||||
commit: PreferenceCommit,
|
||||
): void {
|
||||
renderedShapes.set(container, schemaShape(views));
|
||||
container.replaceChildren(...views.map((view) => renderPreferenceField(container, view, commit)));
|
||||
if (views.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'field-summary';
|
||||
empty.textContent = 'This source has no settings.';
|
||||
container.append(empty);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shown in place of the fields when the source picker is on "All sources". */
|
||||
export function renderPreferencesUnavailable(container: HTMLElement, message: string): void {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'field-summary';
|
||||
note.textContent = message;
|
||||
container.replaceChildren(note);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
|
||||
import type { AnimeBrowserEntry, SourceSearchFailure } from '../types/anime-browser';
|
||||
|
||||
const entry = (title: string): AnimeBrowserEntry => ({
|
||||
url: `/a/${title}`,
|
||||
title,
|
||||
thumbnailUrl: null,
|
||||
sourceId: 's1',
|
||||
sourceName: 'Source One',
|
||||
});
|
||||
|
||||
const failure: SourceSearchFailure = {
|
||||
sourceId: 's2',
|
||||
sourceName: 'Source Two',
|
||||
error: 'login required',
|
||||
};
|
||||
|
||||
test('a search accumulates results and failures update by update', () => {
|
||||
let progress = idleSearchProgress();
|
||||
|
||||
let applied = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 3 });
|
||||
assert.ok(applied);
|
||||
assert.equal(applied.started, true);
|
||||
progress = applied.progress;
|
||||
|
||||
applied = applySearchUpdate(progress, {
|
||||
kind: 'result',
|
||||
token: 1,
|
||||
sourceId: 's1',
|
||||
sourceName: 'Source One',
|
||||
entries: [entry('A'), entry('B')],
|
||||
});
|
||||
assert.ok(applied);
|
||||
assert.deepEqual(
|
||||
applied.entries.map((item) => item.title),
|
||||
['A', 'B'],
|
||||
);
|
||||
progress = applied.progress;
|
||||
|
||||
applied = applySearchUpdate(progress, { kind: 'failure', token: 1, failure });
|
||||
assert.ok(applied);
|
||||
progress = applied.progress;
|
||||
|
||||
assert.equal(progress.entryCount, 2);
|
||||
assert.equal(progress.sourcesDone, 2);
|
||||
assert.deepEqual(progress.failures, [failure]);
|
||||
assert.equal(progress.done, false);
|
||||
|
||||
applied = applySearchUpdate(progress, { kind: 'done', token: 1 });
|
||||
assert.ok(applied);
|
||||
assert.equal(applied.progress.done, true);
|
||||
});
|
||||
|
||||
test('updates from a superseded search are dropped entirely', () => {
|
||||
let progress = idleSearchProgress();
|
||||
progress = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 2 })!.progress;
|
||||
progress = applySearchUpdate(progress, { kind: 'start', token: 2, sourceCount: 2 })!.progress;
|
||||
|
||||
// The first search's straggler results and completion must not touch token 2.
|
||||
assert.equal(
|
||||
applySearchUpdate(progress, {
|
||||
kind: 'result',
|
||||
token: 1,
|
||||
sourceId: 's1',
|
||||
sourceName: 'Source One',
|
||||
entries: [entry('stale')],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(applySearchUpdate(progress, { kind: 'done', token: 1 }), null);
|
||||
assert.equal(progress.entryCount, 0);
|
||||
});
|
||||
|
||||
test('an older start cannot reset a newer search', () => {
|
||||
let progress = idleSearchProgress();
|
||||
progress = applySearchUpdate(progress, { kind: 'start', token: 5, sourceCount: 1 })!.progress;
|
||||
|
||||
assert.equal(applySearchUpdate(progress, { kind: 'start', token: 4, sourceCount: 9 }), null);
|
||||
assert.equal(applySearchUpdate(progress, { kind: 'start', token: 5, sourceCount: 9 }), null);
|
||||
assert.equal(progress.sourceCount, 1);
|
||||
});
|
||||
|
||||
test('summarizeProgress counts sources and names failures', () => {
|
||||
let progress = idleSearchProgress();
|
||||
progress = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 5 })!.progress;
|
||||
progress = applySearchUpdate(progress, {
|
||||
kind: 'result',
|
||||
token: 1,
|
||||
sourceId: 's1',
|
||||
sourceName: 'Source One',
|
||||
entries: [entry('A')],
|
||||
})!.progress;
|
||||
|
||||
assert.equal(summarizeProgress(progress), 'Searching… 1/5 sources · 1 result');
|
||||
|
||||
progress = applySearchUpdate(progress, { kind: 'failure', token: 1, failure })!.progress;
|
||||
assert.equal(
|
||||
summarizeProgress(progress),
|
||||
'Searching… 2/5 sources · 1 result · unavailable: Source Two',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AnimeBrowserEntry, AnimeBrowserSearchUpdate } from '../types/anime-browser';
|
||||
import type { SourceSearchFailure } from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* Streamed-search bookkeeping, kept apart from the DOM so the staleness rules
|
||||
* are testable.
|
||||
*
|
||||
* Updates stream in while earlier searches may still be resolving; a search is
|
||||
* identified by its token and only the newest one may touch the grid. Tokens
|
||||
* are emitted in start order over an ordered channel, so "newest" is simply
|
||||
* the highest token seen.
|
||||
*/
|
||||
|
||||
export interface SearchProgress {
|
||||
token: number;
|
||||
sourceCount: number;
|
||||
sourcesDone: number;
|
||||
entryCount: number;
|
||||
failures: SourceSearchFailure[];
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const IDLE: SearchProgress = {
|
||||
token: -1,
|
||||
sourceCount: 0,
|
||||
sourcesDone: 0,
|
||||
entryCount: 0,
|
||||
failures: [],
|
||||
done: false,
|
||||
};
|
||||
|
||||
export interface AppliedUpdate {
|
||||
progress: SearchProgress;
|
||||
/** Entries this update contributed; append them to the grid. */
|
||||
entries: AnimeBrowserEntry[];
|
||||
/** True when this update began a new search; clear the grid first. */
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
export function idleSearchProgress(): SearchProgress {
|
||||
return { ...IDLE, failures: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one update into the current progress. Returns null for an update from
|
||||
* a superseded search, which the caller must ignore entirely.
|
||||
*/
|
||||
export function applySearchUpdate(
|
||||
current: SearchProgress,
|
||||
update: AnimeBrowserSearchUpdate,
|
||||
): AppliedUpdate | null {
|
||||
if (update.kind === 'start') {
|
||||
// An older search's start (or a replay) must not reset the newer one.
|
||||
if (update.token <= current.token) return null;
|
||||
return {
|
||||
progress: {
|
||||
token: update.token,
|
||||
sourceCount: update.sourceCount,
|
||||
sourcesDone: 0,
|
||||
entryCount: 0,
|
||||
failures: [],
|
||||
done: false,
|
||||
},
|
||||
entries: [],
|
||||
started: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (update.token !== current.token) return null;
|
||||
|
||||
if (update.kind === 'result') {
|
||||
return {
|
||||
progress: {
|
||||
...current,
|
||||
sourcesDone: current.sourcesDone + 1,
|
||||
entryCount: current.entryCount + update.entries.length,
|
||||
},
|
||||
entries: update.entries,
|
||||
started: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (update.kind === 'failure') {
|
||||
return {
|
||||
progress: {
|
||||
...current,
|
||||
sourcesDone: current.sourcesDone + 1,
|
||||
failures: [...current.failures, update.failure],
|
||||
},
|
||||
entries: [],
|
||||
started: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { progress: { ...current, done: true }, entries: [], started: false };
|
||||
}
|
||||
|
||||
/** `Searching… 3/5 sources · 42 results`, with failures named once they exist. */
|
||||
export function summarizeProgress(progress: SearchProgress): string {
|
||||
const counts = `${progress.sourcesDone}/${progress.sourceCount} sources · ${progress.entryCount} result${progress.entryCount === 1 ? '' : 's'}`;
|
||||
const failed =
|
||||
progress.failures.length === 0
|
||||
? ''
|
||||
: ` · unavailable: ${progress.failures.map((failure) => failure.sourceName).join(', ')}`;
|
||||
return `Searching… ${counts}${failed}`;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
@font-face {
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Catppuccin Macchiato */
|
||||
--ctp-mauve: #c6a0f6;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-sky: #91d7e3;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--ctp-overlay1: #8087a2;
|
||||
--ctp-surface0: #363a4f;
|
||||
--ctp-base: #24273a;
|
||||
--ctp-mantle: #1e2030;
|
||||
--ctp-crust: #181926;
|
||||
|
||||
--bg: var(--ctp-base);
|
||||
--panel: rgba(36, 39, 58, 0.85);
|
||||
--panel-elevated: rgba(54, 58, 79, 0.55);
|
||||
--line: rgba(110, 115, 141, 0.28);
|
||||
--text: var(--ctp-text);
|
||||
--muted: var(--ctp-subtext0);
|
||||
--faint: var(--ctp-overlay1);
|
||||
--accent: var(--ctp-blue);
|
||||
--accent-strong: var(--ctp-lavender);
|
||||
|
||||
/* Amber marks the mining action: the thing you highlight to look up. */
|
||||
--mine: var(--ctp-yellow);
|
||||
--danger: var(--ctp-red);
|
||||
--ok: var(--ctp-green);
|
||||
--shadow: rgba(0, 0, 0, 0.42);
|
||||
--mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--ctp-mantle), var(--ctp-base));
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--ctp-crust);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.text-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.ghost-button {
|
||||
padding: 9px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: transparent;
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ghost-button:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--faint);
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.ghost-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.primary-button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover[aria-selected='false'] {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab[aria-selected='true'] {
|
||||
background: var(--panel-elevated);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.source-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.source-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-picker .text-input {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ---------- bridge banner ---------- */
|
||||
|
||||
.bridge-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-elevated);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.bridge-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.bridge-banner[data-stage='ready'] .bridge-dot {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.bridge-banner[data-stage='failed'] .bridge-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.bridge-banner[data-busy='true'] .bridge-dot {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.bridge-meter {
|
||||
width: 160px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--ctp-crust);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bridge-meter i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.results {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 48px auto;
|
||||
max-width: 44ch;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: block;
|
||||
margin: 24px auto 4px;
|
||||
}
|
||||
|
||||
.load-more.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- cover cards ---------- */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
animation: card-in 0.32s ease both;
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.card-art {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--ctp-crust);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 10px 28px -18px var(--shadow);
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.card:hover .card-art,
|
||||
.card:focus-visible .card-art {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-art.is-empty::after {
|
||||
content: attr(data-initial);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* Which source a cover came from, shown only in an all-sources result. */
|
||||
.card-source {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 7px;
|
||||
background: rgba(24, 25, 38, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||
import type { MpvClient } from '../types/runtime';
|
||||
import { resolveMpvHttpHeaders, setHeaderIfMissing } from '../core/services/mpv-http-headers';
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
|
||||
export type MediaGenerationKind = 'audio' | 'video';
|
||||
@@ -37,23 +38,6 @@ export function resolveAudioStreamIndexForMediaGeneration(
|
||||
return audioStreamIndex ?? undefined;
|
||||
}
|
||||
|
||||
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
|
||||
'http-header-fields',
|
||||
'options/http-header-fields',
|
||||
'file-local-options/http-header-fields',
|
||||
] as const;
|
||||
const USER_AGENT_PROPERTY_NAMES = [
|
||||
'file-local-options/user-agent',
|
||||
'options/user-agent',
|
||||
'user-agent',
|
||||
] as const;
|
||||
const REFERRER_PROPERTY_NAMES = [
|
||||
'file-local-options/referrer',
|
||||
'options/referrer',
|
||||
'referrer',
|
||||
] as const;
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
@@ -62,17 +46,6 @@ function trimToNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeHeaderName(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||
return extractFileUrlsFromMpvEdlSource(source)
|
||||
.map((value) => trimToNonEmptyString(value))
|
||||
@@ -198,68 +171,6 @@ function logMediaGenerationInputMiss(
|
||||
);
|
||||
}
|
||||
|
||||
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMpvHeaderField(value: string): [string, string] | null {
|
||||
const separatorIndex = value.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = normalizeHeaderName(value.slice(0, separatorIndex));
|
||||
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
|
||||
if (!name || !headerValue) {
|
||||
return null;
|
||||
}
|
||||
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
||||
}
|
||||
|
||||
function toHeaderFields(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function requestOptionalMpvProperty(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!mpvClient.requestProperty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await mpvClient.requestProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestFirstNonEmptyStringProperty(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
names: readonly string[],
|
||||
): Promise<string | null> {
|
||||
for (const name of names) {
|
||||
const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name));
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveRemoteInputOptions(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
resolvedPath: string,
|
||||
@@ -268,24 +179,7 @@ async function resolveRemoteInputOptions(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
|
||||
const mpvHeaderFields = toHeaderFields(
|
||||
await requestOptionalMpvProperty(mpvClient, propertyName),
|
||||
);
|
||||
for (const field of mpvHeaderFields) {
|
||||
const parsed = parseMpvHeaderField(field);
|
||||
if (parsed) {
|
||||
headers[parsed[0]] = parsed[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES);
|
||||
const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES);
|
||||
if (referrer) {
|
||||
setHeaderIfMissing(headers, 'Referer', referrer);
|
||||
}
|
||||
const { headers, userAgent } = await resolveMpvHttpHeaders(mpvClient);
|
||||
if (isGoogleVideoMediaPath(resolvedPath)) {
|
||||
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
|
||||
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface CliArgs {
|
||||
yomitan: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
animeBrowser: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -125,6 +126,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
animeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -274,6 +276,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--sync-window') args.syncWindow = true;
|
||||
else if (arg === '--anime') args.animeBrowser = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
@@ -549,6 +552,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.animeBrowser ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -628,6 +632,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.yomitan &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.animeBrowser &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -700,6 +705,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.animeBrowser ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -758,6 +764,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.animeBrowser &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
|
||||
@@ -81,6 +81,7 @@ ${B}Jellyfin${R}
|
||||
|
||||
${B}Stats sync${R}
|
||||
--sync-window Open the stats sync window
|
||||
--anime Open the anime browser
|
||||
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
|
||||
${D}run SubMiner --sync-cli --help for details)${R}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const {
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const {
|
||||
ankiConnect,
|
||||
anime,
|
||||
jimaku,
|
||||
tsukihime,
|
||||
anilist,
|
||||
@@ -72,6 +73,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleStyle,
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
anime,
|
||||
jimaku,
|
||||
tsukihime,
|
||||
anilist,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
|
||||
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'ankiConnect'
|
||||
| 'anime'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'anilist'
|
||||
@@ -95,6 +96,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
wordCardKind: 'word-and-sentence',
|
||||
},
|
||||
},
|
||||
anime: {
|
||||
// Empty by default: SubMiner ships no extension repositories and performs
|
||||
// no discovery. Sources exist only once the user adds a repo or an APK.
|
||||
extensionsDir: '',
|
||||
repos: [],
|
||||
preferredQuality: '',
|
||||
},
|
||||
jimaku: {
|
||||
apiBaseUrl: 'https://jimaku.cc',
|
||||
apiKey: '',
|
||||
|
||||
@@ -566,6 +566,27 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||
description: 'mpv key used to skip the detected intro while the skip prompt is visible.',
|
||||
},
|
||||
{
|
||||
path: 'anime.extensionsDir',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.anime.extensionsDir,
|
||||
description:
|
||||
'Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.',
|
||||
},
|
||||
{
|
||||
path: 'anime.repos',
|
||||
kind: 'array',
|
||||
defaultValue: defaultConfig.anime.repos,
|
||||
description:
|
||||
'Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.',
|
||||
},
|
||||
{
|
||||
path: 'anime.preferredQuality',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.anime.preferredQuality,
|
||||
description:
|
||||
'Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -141,6 +141,15 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'ankiConnect',
|
||||
},
|
||||
{
|
||||
title: 'Anime Browser',
|
||||
description: [
|
||||
'Anime browser sources. SubMiner ships no extension repositories and bundles no sources;',
|
||||
'add a repository index URL here (or drop .apk files in the extensions directory) to have any.',
|
||||
],
|
||||
notes: ['Hot-reload: anime changes apply the next time the anime browser opens.'],
|
||||
key: 'anime',
|
||||
},
|
||||
{
|
||||
title: 'Jimaku',
|
||||
description: ['Jimaku API configuration and defaults.'],
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as path from 'node:path';
|
||||
import { MPV_LAUNCH_MODE_VALUES, parseMpvLaunchMode } from '../../shared/mpv-launch-mode';
|
||||
import { ResolveContext } from './context';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
import { isValidRepoUrl } from '../../anime-bridge/extension-repo';
|
||||
|
||||
function normalizeExternalProfilePath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
@@ -343,6 +344,51 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
||||
}
|
||||
|
||||
if (isObject(src.anime)) {
|
||||
const extensionsDir = asString(src.anime.extensionsDir);
|
||||
if (extensionsDir !== undefined) {
|
||||
resolved.anime.extensionsDir = normalizeExternalProfilePath(extensionsDir);
|
||||
} else if (src.anime.extensionsDir !== undefined) {
|
||||
warn(
|
||||
'anime.extensionsDir',
|
||||
src.anime.extensionsDir,
|
||||
resolved.anime.extensionsDir,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
const preferredQuality = asString(src.anime.preferredQuality);
|
||||
if (preferredQuality !== undefined) {
|
||||
resolved.anime.preferredQuality = preferredQuality.trim();
|
||||
} else if (src.anime.preferredQuality !== undefined) {
|
||||
warn(
|
||||
'anime.preferredQuality',
|
||||
src.anime.preferredQuality,
|
||||
resolved.anime.preferredQuality,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(src.anime.repos)) {
|
||||
const repos: string[] = [];
|
||||
for (const entry of src.anime.repos) {
|
||||
const url = asString(entry)?.trim();
|
||||
// Rejecting here rather than at fetch time keeps a bad entry from
|
||||
// silently doing nothing, and blocks plain http downgrades.
|
||||
if (url !== undefined && isValidRepoUrl(url)) {
|
||||
if (!repos.includes(url)) repos.push(url);
|
||||
} else {
|
||||
warn('anime.repos[]', entry, null, 'Expected an https URL to a .json index file.');
|
||||
}
|
||||
}
|
||||
resolved.anime.repos = repos;
|
||||
} else if (src.anime.repos !== undefined) {
|
||||
warn('anime.repos', src.anime.repos, resolved.anime.repos, 'Expected array of strings.');
|
||||
}
|
||||
} else if (src.anime !== undefined) {
|
||||
warn('anime', src.anime, resolved.anime, 'Expected object.');
|
||||
}
|
||||
|
||||
if (isObject(src.jellyfin)) {
|
||||
const enabled = asBoolean(src.jellyfin.enabled);
|
||||
if (enabled !== undefined) {
|
||||
|
||||
@@ -1805,7 +1805,7 @@ describe('stats server API routes', () => {
|
||||
Stale English subtitle
|
||||
`;
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
fs.writeFileSync(alassPath, 'fake alass');
|
||||
fs.writeFileSync(alassPath, 'fake alass', { mode: 0o755 });
|
||||
fs.writeFileSync(
|
||||
japanesePath,
|
||||
`1
|
||||
@@ -1865,7 +1865,7 @@ Aligned English subtitle
|
||||
const englishPath = path.join(dir, 'episode.en.srt');
|
||||
const alassPath = path.join(dir, 'alass-cli');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
fs.writeFileSync(alassPath, 'fake alass');
|
||||
fs.writeFileSync(alassPath, 'fake alass', { mode: 0o755 });
|
||||
fs.writeFileSync(
|
||||
japanesePath,
|
||||
`1
|
||||
|
||||
@@ -13,7 +13,8 @@ export interface AnilistMediaGuess {
|
||||
year?: number;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
source: 'guessit' | 'fallback';
|
||||
/** `stream` means the player was handed these fields, not that we parsed them. */
|
||||
source: 'guessit' | 'fallback' | 'stream';
|
||||
}
|
||||
|
||||
export interface AnilistPostWatchUpdateResult {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
guessAnilistMediaInfo,
|
||||
runGuessit,
|
||||
type AnilistMediaGuess,
|
||||
type GuessAnilistMediaInfoDeps,
|
||||
} from './anilist-updater';
|
||||
import {
|
||||
@@ -32,7 +33,7 @@ interface Logger {
|
||||
|
||||
interface CoverArtCandidate {
|
||||
title: string;
|
||||
source: 'guessit' | 'fallback';
|
||||
source: AnilistMediaGuess['source'];
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ANILIST_SEASON_SEARCH_QUERY,
|
||||
pickByAirOrder,
|
||||
resolveAnilistSeasonMedia,
|
||||
splitPartMarker,
|
||||
stripSeasonSuffix,
|
||||
type AnilistSeasonMedia,
|
||||
} from './season-resolver';
|
||||
@@ -305,3 +306,329 @@ test('air-order fallback declines when a franchise entry has no air year', async
|
||||
assert.equal(result?.seasonResolved, false);
|
||||
assert.equal(result?.via, 'anchor');
|
||||
});
|
||||
|
||||
/**
|
||||
* Real AniList payloads for Mushoku Tensei, whose sequel chain interleaves
|
||||
* split-cour continuations with real seasons:
|
||||
* S1 -> "Cour 2" -> S2 -> "Season 2 Part 2" -> S3.
|
||||
*/
|
||||
const MUSHOKU_SEARCH: AnilistSeasonMedia[] = [
|
||||
{
|
||||
id: 108465,
|
||||
episodes: 11,
|
||||
format: 'TV',
|
||||
seasonYear: 2021,
|
||||
title: {
|
||||
romaji: 'Mushoku Tensei: Isekai Ittara Honki Dasu',
|
||||
english: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 127720,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
seasonYear: 2021,
|
||||
title: {
|
||||
romaji: 'Mushoku Tensei: Isekai Ittara Honki Dasu Part 2',
|
||||
english: 'Mushoku Tensei: Jobless Reincarnation Cour 2',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 146065,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2023,
|
||||
title: {
|
||||
romaji: 'Mushoku Tensei II: Isekai Ittara Honki Dasu',
|
||||
english: 'Mushoku Tensei: Jobless Reincarnation Season 2',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 166873,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
seasonYear: 2024,
|
||||
title: {
|
||||
romaji: 'Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2',
|
||||
english: 'Mushoku Tensei: Jobless Reincarnation Season 2 Part 2',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 178789,
|
||||
episodes: 14,
|
||||
format: 'TV',
|
||||
seasonYear: 2026,
|
||||
title: {
|
||||
romaji: 'Mushoku Tensei III: Isekai Ittara Honki Dasu',
|
||||
english: 'Mushoku Tensei: Jobless Reincarnation Season 3',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const MUSHOKU_RELATIONS: Record<
|
||||
number,
|
||||
Array<{ relationType: string; node: AnilistSeasonMedia }>
|
||||
> = {
|
||||
108465: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[1]! }],
|
||||
127720: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[2]! }],
|
||||
146065: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[3]! }],
|
||||
166873: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[4]! }],
|
||||
};
|
||||
|
||||
test('splitPartMarker separates a split-cour marker from the season title', () => {
|
||||
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Season 2 Part 2'), {
|
||||
base: 'mushoku tensei: jobless reincarnation season 2',
|
||||
hasPart: true,
|
||||
});
|
||||
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Cour 2'), {
|
||||
base: 'mushoku tensei: jobless reincarnation',
|
||||
hasPart: true,
|
||||
});
|
||||
assert.deepEqual(splitPartMarker('Attack on Titan Final Season Part 3'), {
|
||||
base: 'attack on titan final season',
|
||||
hasPart: true,
|
||||
});
|
||||
assert.deepEqual(splitPartMarker('進撃の巨人 第2部'), { base: '進撃の巨人', hasPart: true });
|
||||
});
|
||||
|
||||
test('splitPartMarker leaves a numbered work alone', () => {
|
||||
// The number is followed by a subtitle, so it names the work, not a cour.
|
||||
assert.deepEqual(splitPartMarker('JoJo no Kimyou na Bouken Part 5: Ougon no Kaze'), {
|
||||
base: 'jojo no kimyou na bouken part 5: ougon no kaze',
|
||||
hasPart: false,
|
||||
});
|
||||
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Season 3'), {
|
||||
base: 'mushoku tensei: jobless reincarnation season 3',
|
||||
hasPart: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a split-cour continuation does not consume a season step', async () => {
|
||||
const { execute, relationLookups } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 3, episode: 4 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 178789);
|
||||
assert.equal(result?.title, 'Mushoku Tensei: Jobless Reincarnation Season 3');
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
assert.equal(result?.via, 'sequel-chain');
|
||||
// Four hops for three seasons: two of them were cour continuations.
|
||||
assert.deepEqual(relationLookups, [108465, 127720, 146065, 166873]);
|
||||
});
|
||||
|
||||
test('season 2 stops at the front half rather than its second cour', async () => {
|
||||
const { execute } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 2, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 146065);
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
});
|
||||
|
||||
test('air-order fallback skips split-cour entries too', () => {
|
||||
const anchor = MUSHOKU_SEARCH[0]!;
|
||||
assert.equal(pickByAirOrder(anchor, 2, MUSHOKU_SEARCH)?.id, 146065);
|
||||
assert.equal(pickByAirOrder(anchor, 3, MUSHOKU_SEARCH)?.id, 178789);
|
||||
// Only three real seasons exist, so a fourth must not fall out of the list.
|
||||
assert.equal(pickByAirOrder(anchor, 4, MUSHOKU_SEARCH), null);
|
||||
});
|
||||
|
||||
test('a sequel that repeats its predecessor name is still a new season', async () => {
|
||||
// No part marker, so the identical title must not be read as a continuation.
|
||||
const search: AnilistSeasonMedia[] = [
|
||||
{
|
||||
id: 1,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
seasonYear: 2020,
|
||||
title: { romaji: 'Same Name Show', english: 'Same Name Show', native: null },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
seasonYear: 2022,
|
||||
title: { romaji: 'Same Name Show', english: 'Same Name Show', native: null },
|
||||
},
|
||||
];
|
||||
const { execute } = createExecutor(search, {
|
||||
1: [{ relationType: 'SEQUEL', node: search[1]! }],
|
||||
});
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'Same Name Show', season: 2, episode: 3 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 2);
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Real AniList payloads for Bleach TYBW, whose cours are titled with their own
|
||||
* arc names — the part markers live only in localized synonyms, and the English
|
||||
* title hyphenates "Thousand-Year" while those synonyms do not.
|
||||
*/
|
||||
const BLEACH_SEARCH: AnilistSeasonMedia[] = [
|
||||
{
|
||||
id: 116674,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2022,
|
||||
synonyms: ['BLEACH TYBW'],
|
||||
title: {
|
||||
romaji: 'BLEACH: Sennen Kessen-hen',
|
||||
english: 'BLEACH: Thousand-Year Blood War',
|
||||
native: 'BLEACH 千年血戦篇',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 159322,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2023,
|
||||
synonyms: [
|
||||
'BLEACH: Thousand Year Blood War Part 2',
|
||||
'BLEACH 千年血戦篇 第2クール',
|
||||
'BLEACH TYBW',
|
||||
],
|
||||
title: {
|
||||
romaji: 'BLEACH: Sennen Kessen-hen - Ketsubetsu-tan',
|
||||
english: 'BLEACH: Thousand-Year Blood War - The Separation',
|
||||
native: 'BLEACH 千年血戦篇-訣別譚-',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('a cour marked only in a synonym, and spelled without the hyphen, still counts as one', async () => {
|
||||
const { execute } = createExecutor(BLEACH_SEARCH, {
|
||||
116674: [{ relationType: 'SEQUEL', node: BLEACH_SEARCH[1]! }],
|
||||
});
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'BLEACH: Thousand-Year Blood War', season: 2, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
// The only sequel is cour 2 of the same season, so there is no season 2 to
|
||||
// find — reporting that beats confidently returning the wrong arc.
|
||||
assert.equal(result?.seasonResolved, false);
|
||||
assert.notEqual(result?.id, 159322);
|
||||
});
|
||||
|
||||
test('splitPartMarker reads the Japanese cour marker', () => {
|
||||
assert.deepEqual(splitPartMarker('BLEACH 千年血戦篇 第2クール'), {
|
||||
base: 'bleach 千年血戦篇',
|
||||
hasPart: true,
|
||||
});
|
||||
});
|
||||
|
||||
/** Dr. STONE routes its sequel chain through a one-episode special. */
|
||||
const DR_STONE_SEARCH: AnilistSeasonMedia[] = [
|
||||
{
|
||||
id: 105333,
|
||||
episodes: 24,
|
||||
format: 'TV',
|
||||
seasonYear: 2019,
|
||||
title: { romaji: 'Dr. STONE', english: 'Dr. STONE', native: null },
|
||||
},
|
||||
{
|
||||
id: 113936,
|
||||
episodes: 11,
|
||||
format: 'TV',
|
||||
seasonYear: 2021,
|
||||
title: { romaji: 'Dr. STONE: STONE WARS', english: 'Dr. STONE: STONE WARS', native: null },
|
||||
},
|
||||
{
|
||||
id: 142876,
|
||||
episodes: 1,
|
||||
format: 'SPECIAL',
|
||||
seasonYear: 2022,
|
||||
title: {
|
||||
romaji: 'Dr. STONE: Ryuusui',
|
||||
english: 'Dr. STONE Special Episode – RYUSUI',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 131518,
|
||||
episodes: 11,
|
||||
format: 'TV',
|
||||
seasonYear: 2023,
|
||||
title: { romaji: 'Dr. STONE: NEW WORLD', english: 'Dr. STONE New World', native: null },
|
||||
},
|
||||
];
|
||||
|
||||
test('a special in the sequel chain is walked through but does not count as a season', async () => {
|
||||
const { execute } = createExecutor(DR_STONE_SEARCH, {
|
||||
105333: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[1]! }],
|
||||
113936: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[2]! }],
|
||||
142876: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[3]! }],
|
||||
});
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'Dr. STONE', season: 3, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 131518);
|
||||
assert.equal(result?.title, 'Dr. STONE New World');
|
||||
assert.equal(result?.seasonResolved, true);
|
||||
});
|
||||
|
||||
test('air-order fallback keeps a season whose own synonym spells it as a part', () => {
|
||||
// AniList carries "…Season 3 Part 1" as a localized synonym *of season 3*.
|
||||
const search: AnilistSeasonMedia[] = [
|
||||
{
|
||||
id: 1,
|
||||
episodes: 25,
|
||||
format: 'TV',
|
||||
seasonYear: 2013,
|
||||
title: { romaji: 'Some Titans', english: 'Some Titans', native: null },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
seasonYear: 2017,
|
||||
title: { romaji: 'Some Titans Season 2', english: 'Some Titans Season 2', native: null },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
seasonYear: 2018,
|
||||
synonyms: ['Some Titans Season 3 Part 1'],
|
||||
title: { romaji: 'Some Titans Season 3', english: 'Some Titans Season 3', native: null },
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(pickByAirOrder(search[0]!, 3, search)?.id, 3);
|
||||
});
|
||||
|
||||
test('a transport failure mid-walk is surfaced instead of guessed around', async () => {
|
||||
const { execute } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
|
||||
const failing = async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
|
||||
if (query === ANILIST_SEASON_RELATIONS_QUERY) {
|
||||
throw new Error('Too Many Requests.');
|
||||
}
|
||||
return execute<T>(query, variables);
|
||||
};
|
||||
|
||||
// Air order could answer here, but it would be answering from a franchise
|
||||
// picture the failed walk never confirmed.
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveAnilistSeasonMedia(
|
||||
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 3, episode: 4 },
|
||||
{ execute: failing },
|
||||
),
|
||||
/Too Many Requests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -115,10 +115,45 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
|
||||
|
||||
const MAX_SEQUEL_HOPS = 12;
|
||||
|
||||
/**
|
||||
* A split-cour continuation adds sequel hops without adding a season, so the
|
||||
* walk needs room for more hops than the season number alone implies.
|
||||
*/
|
||||
const MAX_SEQUEL_STEPS = 24;
|
||||
|
||||
/**
|
||||
* Markers AniList appends when one broadcast season is listed as several
|
||||
* entries. Anchored to the end so a numbered *work* ("JoJo no Kimyou na Bouken
|
||||
* Part 5: Ougon no Kaze") is not mistaken for a continuation — those carry a
|
||||
* subtitle after the number and really are separate seasons.
|
||||
*/
|
||||
const PART_MARKER_PATTERNS: RegExp[] = [
|
||||
/\bpart\s*(?:\d{1,2}|i{1,3}|iv|v)\s*$/i,
|
||||
/\bcour\s*\d{1,2}\s*$/i,
|
||||
/\b\d{1,2}(?:st|nd|rd|th)\s+(?:part|cour)\s*$/i,
|
||||
/\b(?:second|final)\s+(?:part|cour)\s*$/i,
|
||||
/\s*第\s*\d{1,2}\s*(?:部|クール)\s*$/,
|
||||
];
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison key that ignores punctuation.
|
||||
*
|
||||
* AniList spells the same name differently across a franchise's own entries —
|
||||
* "BLEACH: Thousand-Year Blood War" against the synonym "BLEACH: Thousand Year
|
||||
* Blood War Part 2" — so an exact string compare misses the continuation.
|
||||
*/
|
||||
function titleMatchKey(value: string): string {
|
||||
return value
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops season markers a release name carries but AniList titles never do,
|
||||
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
|
||||
@@ -132,6 +167,50 @@ export function stripSeasonSuffix(title: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a trailing part/cour marker off a title.
|
||||
*
|
||||
* "Mushoku Tensei: Jobless Reincarnation Season 2 Part 2" is the back half of
|
||||
* season 2, not season 3 — AniList lists it as its own SEQUEL of the front
|
||||
* half, which is what used to make every split-cour franchise resolve one
|
||||
* season short.
|
||||
*/
|
||||
export function splitPartMarker(title: string): { base: string; hasPart: boolean } {
|
||||
const trimmed = title.trim();
|
||||
for (const pattern of PART_MARKER_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (!match || match.index === undefined) continue;
|
||||
const base = normalizeTitle(trimmed.slice(0, match.index));
|
||||
if (!base) continue;
|
||||
return { base, hasPart: true };
|
||||
}
|
||||
return { base: normalizeTitle(trimmed), hasPart: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `next` continues `current`'s season rather than starting a new one.
|
||||
*
|
||||
* The marker is required, not just a matching base title: a sequel that repeats
|
||||
* its predecessor's name exactly is a new season that AniList simply did not
|
||||
* number, and skipping it would undercount in the other direction.
|
||||
*/
|
||||
function isSeasonContinuation(current: AnilistSeasonMedia, next: AnilistSeasonMedia): boolean {
|
||||
const parts = mediaTitles(next)
|
||||
.map((title) => splitPartMarker(title))
|
||||
.filter((part) => part.hasPart);
|
||||
if (parts.length === 0) return false;
|
||||
|
||||
// Both the full title and its own stripped base, so a "Part 3" can be matched
|
||||
// against a season the walk only ever saw as its "Part 2".
|
||||
const currentKeys = new Set(
|
||||
mediaTitles(current).flatMap((title) => [
|
||||
titleMatchKey(title),
|
||||
titleMatchKey(splitPartMarker(title).base),
|
||||
]),
|
||||
);
|
||||
return parts.some((part) => currentKeys.has(titleMatchKey(part.base)));
|
||||
}
|
||||
|
||||
function mediaTitles(media: AnilistSeasonMedia): string[] {
|
||||
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
||||
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
||||
@@ -250,16 +329,21 @@ async function walkSequelChain(
|
||||
season: number,
|
||||
deps: ResolveAnilistSeasonMediaDeps,
|
||||
): Promise<AnilistSeasonMedia | null> {
|
||||
// Walking fewer hops than requested would land on the wrong season and report it as
|
||||
// Advancing fewer seasons than requested would land on the wrong one and report it as
|
||||
// resolved, so refuse instead and let the caller fall through to its guarded fallback.
|
||||
const hops = season - 1;
|
||||
if (hops > MAX_SEQUEL_HOPS) {
|
||||
const targetAdvances = season - 1;
|
||||
if (targetAdvances > MAX_SEQUEL_HOPS) {
|
||||
return null;
|
||||
}
|
||||
const visited = new Set<number>([anchor.id]);
|
||||
let current = anchor;
|
||||
// The last entry that counted as a season. Continuations are compared against
|
||||
// this rather than against `current`, so a special sitting between a season
|
||||
// and its second cour cannot hide the link between them.
|
||||
let seasonStart = anchor;
|
||||
let advances = 0;
|
||||
|
||||
for (let hop = 0; hop < hops; hop += 1) {
|
||||
for (let step = 0; step < MAX_SEQUEL_STEPS && advances < targetAdvances; step += 1) {
|
||||
// Transport errors propagate: a failed hop must not be mistaken for "no sequel exists".
|
||||
const response = await deps.execute<AnilistSeasonRelationsResponse>(
|
||||
ANILIST_SEASON_RELATIONS_QUERY,
|
||||
@@ -296,11 +380,25 @@ async function walkSequelChain(
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
current = sequels[0]!;
|
||||
const next = sequels[0]!;
|
||||
// Two kinds of hop move along the chain without moving to the next season:
|
||||
// the back half of a split cour, which is its own SEQUEL entry, and a
|
||||
// special or OVA, which is not a season at all. Both are still traversed,
|
||||
// because the rest of the franchise hangs off them.
|
||||
if (isSeasonalFormat(next) && !isSeasonContinuation(seasonStart, next)) {
|
||||
advances += 1;
|
||||
seasonStart = next;
|
||||
}
|
||||
current = next;
|
||||
visited.add(current.id);
|
||||
}
|
||||
|
||||
return current.id === anchor.id ? null : current;
|
||||
if (advances < targetAdvances) {
|
||||
return null;
|
||||
}
|
||||
// seasonStart, not current: it is the entry the season count landed on, and
|
||||
// it is always a seasonal format because only those advance the count.
|
||||
return seasonStart.id === anchor.id ? null : seasonStart;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,13 +421,31 @@ export function pickByAirOrder(
|
||||
stripSeasonSuffix(candidate).includes(anchorBase),
|
||||
);
|
||||
});
|
||||
if (franchise.length < season) return null;
|
||||
|
||||
// Drop the back halves of split-cour seasons, which are separate entries here
|
||||
// but not separate seasons — counting them shifts every later season index.
|
||||
// The comparison excludes the entry's own titles: a localized synonym often
|
||||
// spells a season as "<season> Part 1", which would otherwise delete the very
|
||||
// season it names.
|
||||
const seasons = franchise.filter((entry) => {
|
||||
if (entry.id === anchor.id) return true;
|
||||
const otherKeys = new Set(
|
||||
franchise
|
||||
.filter((other) => other.id !== entry.id)
|
||||
.flatMap((other) => mediaTitles(other).map((title) => titleMatchKey(title))),
|
||||
);
|
||||
return !mediaTitles(entry).some((title) => {
|
||||
const part = splitPartMarker(title);
|
||||
return part.hasPart && otherKeys.has(titleMatchKey(part.base));
|
||||
});
|
||||
});
|
||||
if (seasons.length < season) return null;
|
||||
|
||||
// Ordering by air date is only meaningful when every entry has one: an unknown year
|
||||
// would sort last and shift the season index while still reporting a resolved match.
|
||||
if (franchise.some((entry) => airYear(entry) === null)) return null;
|
||||
if (seasons.some((entry) => airYear(entry) === null)) return null;
|
||||
|
||||
const ordered = [...franchise].sort((a, b) => {
|
||||
const ordered = [...seasons].sort((a, b) => {
|
||||
const yearDelta = (airYear(a) ?? 0) - (airYear(b) ?? 0);
|
||||
if (yearDelta !== 0) return yearDelta;
|
||||
return a.id - b.id;
|
||||
@@ -386,6 +502,14 @@ export async function resolveAnilistSeasonMedia(
|
||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
|
||||
}
|
||||
|
||||
// The chain failed for transport reasons rather than because the season is absent, so
|
||||
// nothing was learned about the franchise's shape. Surface it and let the caller retry:
|
||||
// the air-order fallback is for a chain that answered and had no edge to follow, and
|
||||
// running it here would turn a rate limit into a confident wrong season.
|
||||
if (chainError) {
|
||||
throw chainError;
|
||||
}
|
||||
|
||||
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||
if (viaAirOrder) {
|
||||
deps.logInfo?.(
|
||||
@@ -394,12 +518,6 @@ export async function resolveAnilistSeasonMedia(
|
||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
|
||||
}
|
||||
|
||||
// The chain failed for transport reasons rather than because the season is absent;
|
||||
// surface that so callers retry instead of reporting an unresolvable season.
|
||||
if (chainError) {
|
||||
throw chainError;
|
||||
}
|
||||
|
||||
deps.logInfo?.(
|
||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
animeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -186,7 +186,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
(!deps.isDarwinPlatform() ||
|
||||
initialArgs.settings ||
|
||||
initialArgs.setup ||
|
||||
initialArgs.syncWindow)
|
||||
initialArgs.syncWindow ||
|
||||
initialArgs.animeBrowser)
|
||||
) {
|
||||
deps.quitApp();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
animeBrowser: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -142,6 +143,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openSyncUiWindow: () => {
|
||||
calls.push('openSyncUiWindow');
|
||||
},
|
||||
openAnimeBrowserWindow: () => {
|
||||
calls.push('openAnimeBrowserWindow');
|
||||
},
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
@@ -665,6 +669,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
openAnimeBrowserWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface CliCommandServiceDeps {
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openAnimeBrowserWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -172,6 +173,7 @@ interface UiCliRuntime {
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openAnimeBrowserWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -277,6 +279,7 @@ export function createCliCommandDepsRuntime(
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||
openAnimeBrowserWindow: options.ui.openAnimeBrowserWindow,
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||
@@ -422,6 +425,8 @@ export function handleCliCommand(
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.syncWindow) {
|
||||
deps.openSyncUiWindow();
|
||||
} else if (args.animeBrowser) {
|
||||
deps.openAnimeBrowserWindow();
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
} else if (args.hide || args.hideVisibleOverlay) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { beforeEach } from 'node:test';
|
||||
import {
|
||||
isDockIconRetained,
|
||||
releaseDockIcon,
|
||||
resetDockIconRetentionForTests,
|
||||
retainDockIcon,
|
||||
} from './dock-icon-visibility';
|
||||
|
||||
function createDockRecorder() {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
dock: {
|
||||
show: () => {
|
||||
calls.push('show');
|
||||
},
|
||||
hide: () => {
|
||||
calls.push('hide');
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetDockIconRetentionForTests();
|
||||
});
|
||||
|
||||
test('retainDockIcon shows the dock once and marks retention on darwin', () => {
|
||||
const recorder = createDockRecorder();
|
||||
|
||||
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
|
||||
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
|
||||
|
||||
assert.deepEqual(recorder.calls, ['show']);
|
||||
assert.equal(isDockIconRetained(), true);
|
||||
});
|
||||
|
||||
test('releaseDockIcon rehides only when the last retainer releases and overlay still needs it', () => {
|
||||
const recorder = createDockRecorder();
|
||||
|
||||
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
|
||||
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
|
||||
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
|
||||
assert.deepEqual(recorder.calls, ['show']);
|
||||
assert.equal(isDockIconRetained(), true);
|
||||
|
||||
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
|
||||
assert.deepEqual(recorder.calls, ['show', 'hide']);
|
||||
assert.equal(isDockIconRetained(), false);
|
||||
});
|
||||
|
||||
test('releaseDockIcon leaves the dock visible when no overlay needs the accessory transform', () => {
|
||||
const recorder = createDockRecorder();
|
||||
|
||||
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
|
||||
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => false });
|
||||
|
||||
assert.deepEqual(recorder.calls, ['show']);
|
||||
assert.equal(isDockIconRetained(), false);
|
||||
});
|
||||
|
||||
test('release without retain and non-darwin platforms are no-ops', () => {
|
||||
const recorder = createDockRecorder();
|
||||
|
||||
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
|
||||
retainDockIcon({ dock: recorder.dock, platform: 'linux' });
|
||||
releaseDockIcon({ dock: recorder.dock, platform: 'win32', shouldRehide: () => true });
|
||||
|
||||
assert.deepEqual(recorder.calls, []);
|
||||
assert.equal(isDockIconRetained(), false);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// macOS-only: Electron's setVisibleOnAllWorkspaces(..., { visibleOnFullScreen: true })
|
||||
// transforms the whole app into a UIElement (accessory) process so overlay windows can
|
||||
// float above fullscreen Spaces. That side effect removes the Dock icon and the app's
|
||||
// Cmd+Tab entry, making regular windows (anime browser, settings) unreachable.
|
||||
//
|
||||
// While at least one regular window "retains" the Dock icon, overlay/stats level
|
||||
// assertions must pass skipTransformProcessType so they don't yank the app back to
|
||||
// accessory mode behind the user's back.
|
||||
|
||||
type DockLike = {
|
||||
show: () => Promise<void> | void;
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
let dockIconRetainCount = 0;
|
||||
|
||||
export function isDockIconRetained(): boolean {
|
||||
return dockIconRetainCount > 0;
|
||||
}
|
||||
|
||||
export function retainDockIcon(options: {
|
||||
dock: DockLike | null | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
}): void {
|
||||
if ((options.platform ?? process.platform) !== 'darwin') return;
|
||||
dockIconRetainCount += 1;
|
||||
if (dockIconRetainCount === 1) {
|
||||
void options.dock?.show();
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseDockIcon(options: {
|
||||
dock: DockLike | null | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
// True when an overlay window still needs the accessory transform to float
|
||||
// above fullscreen Spaces; false leaves the Dock icon visible.
|
||||
shouldRehide: () => boolean;
|
||||
}): void {
|
||||
if ((options.platform ?? process.platform) !== 'darwin') return;
|
||||
if (dockIconRetainCount === 0) return;
|
||||
dockIconRetainCount -= 1;
|
||||
if (dockIconRetainCount === 0 && options.shouldRehide()) {
|
||||
options.dock?.hide();
|
||||
}
|
||||
}
|
||||
|
||||
export function resetDockIconRetentionForTests(): void {
|
||||
dockIconRetainCount = 0;
|
||||
}
|
||||
@@ -4313,3 +4313,116 @@ test('ensureAnimeCoverArt fetches art via the latest video of the anime', async
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('anime browser streams group by series instead of by the proxy file extension', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
// Two different shows, each behind the strip proxy. Without the metadata
|
||||
// recorded up front the only readable part of either URL is ".m3u8", which
|
||||
// is what used to collapse them into one series.
|
||||
tracker.recordStreamPlaybackMetadata({
|
||||
mediaPath: 'http://127.0.0.1:41234/video/9f2c1b7a.m3u8',
|
||||
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
|
||||
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
});
|
||||
tracker.handleMediaChange(
|
||||
'http://127.0.0.1:41234/video/9f2c1b7a.m3u8',
|
||||
'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
);
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
tracker.handleMediaChange(null, null);
|
||||
|
||||
tracker.recordStreamPlaybackMetadata({
|
||||
mediaPath: 'http://127.0.0.1:41234/video/33ab90ff.m3u8',
|
||||
statsPath: 'animebrowser://9001/%2Fanime%2Fsnafu/%2Fwatch%2Fep-10',
|
||||
displayTitle: 'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
|
||||
seriesTitle: 'My Teen Romantic Comedy SNAFU Climax!',
|
||||
seasonNumber: null,
|
||||
episodeNumber: 10,
|
||||
});
|
||||
tracker.handleMediaChange(
|
||||
'http://127.0.0.1:41234/video/33ab90ff.m3u8',
|
||||
'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
|
||||
);
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
tracker.handleMediaChange(null, null);
|
||||
|
||||
// The same episode again: a fresh proxy port and token, which must not mint
|
||||
// a second video row.
|
||||
tracker.recordStreamPlaybackMetadata({
|
||||
mediaPath: 'http://127.0.0.1:55555/video/ffff0000.m3u8',
|
||||
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
|
||||
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
});
|
||||
tracker.handleMediaChange(
|
||||
'http://127.0.0.1:55555/video/ffff0000.m3u8',
|
||||
'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
);
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const rows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.source_url,
|
||||
v.canonical_title AS video_title,
|
||||
v.parsed_title,
|
||||
v.parsed_season,
|
||||
v.parsed_episode,
|
||||
v.parser_source,
|
||||
a.canonical_title AS anime_title
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
ORDER BY v.video_id
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
source_url: string | null;
|
||||
video_title: string;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
parsed_episode: number | null;
|
||||
parser_source: string | null;
|
||||
anime_title: string;
|
||||
}>;
|
||||
|
||||
// Two episodes, three playbacks: the rewatch reused its row.
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 2);
|
||||
assert.equal(
|
||||
rows.some((row) => row.anime_title.toLowerCase().includes('m3u8')),
|
||||
false,
|
||||
);
|
||||
|
||||
const mushoku = rows.find((row) => row.parsed_title?.startsWith('Mushoku'));
|
||||
assert.ok(mushoku);
|
||||
assert.equal(mushoku.source_url, 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4');
|
||||
assert.equal(mushoku.video_title, 'Mushoku Tensei: Jobless Reincarnation S03E04');
|
||||
assert.equal(mushoku.parsed_title, 'Mushoku Tensei: Jobless Reincarnation');
|
||||
assert.equal(mushoku.parsed_season, 3);
|
||||
assert.equal(mushoku.parsed_episode, 4);
|
||||
assert.equal(mushoku.parser_source, 'anime-browser');
|
||||
assert.equal(mushoku.anime_title, 'Mushoku Tensei: Jobless Reincarnation Season 3');
|
||||
|
||||
const snafu = rows.find((row) => row.parsed_title?.startsWith('My Teen'));
|
||||
assert.ok(snafu);
|
||||
assert.equal(snafu.parsed_episode, 10);
|
||||
assert.equal(snafu.parsed_season, null);
|
||||
assert.equal(snafu.anime_title, 'My Teen Romantic Comedy SNAFU Climax!');
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -320,6 +320,31 @@ export interface JellyfinPlaybackMetadataInput {
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An episode the anime browser is about to stream.
|
||||
*
|
||||
* Reported up front for the same reason the Jellyfin variant is: the stream URL
|
||||
* mpv sees is a per-playback proxy address ending in `.m3u8`, so a session
|
||||
* started from it alone lands in a series named after the file extension.
|
||||
*/
|
||||
export interface StreamPlaybackMetadataInput {
|
||||
/** The URL handed to mpv; recorded as an alias of `statsPath`. */
|
||||
mediaPath: string;
|
||||
/** Stable per-episode identity. Survives the proxy port and token changing. */
|
||||
statsPath: string;
|
||||
displayTitle: string;
|
||||
seriesTitle: string;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser sources that are recorded before playback starts. A video carrying one
|
||||
* already has better metadata than filename guessing could produce, so the
|
||||
* guess is skipped rather than allowed to overwrite it.
|
||||
*/
|
||||
const PREPLAYBACK_PARSER_SOURCES = new Set(['jellyfin', 'anime-browser']);
|
||||
|
||||
function normalizeMetadataInt(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
@@ -353,7 +378,7 @@ function deleteSearchParamsCaseInsensitive(searchParams: URLSearchParams, names:
|
||||
}
|
||||
}
|
||||
|
||||
function buildJellyfinMediaPathAliasCandidates(mediaPath: string): string[] {
|
||||
function buildMediaPathAliasCandidates(mediaPath: string): string[] {
|
||||
const candidates = new Set<string>([mediaPath]);
|
||||
try {
|
||||
const parsed = new URL(mediaPath);
|
||||
@@ -1251,15 +1276,11 @@ export class ImmersionTrackerService {
|
||||
if (!rawPath) {
|
||||
return;
|
||||
}
|
||||
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||
for (const alias of buildJellyfinMediaPathAliasCandidates(rawPath)) {
|
||||
this.mediaPathAliases.set(alias, normalizedPath);
|
||||
}
|
||||
|
||||
const statsPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||
const displayTitle =
|
||||
normalizeText(metadata.displayTitle) ||
|
||||
normalizeText(metadata.itemTitle) ||
|
||||
deriveCanonicalTitle(normalizedPath);
|
||||
deriveCanonicalTitle(statsPath);
|
||||
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
|
||||
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||
const libraryTitle = seriesTitle || itemTitle;
|
||||
@@ -1269,47 +1290,110 @@ export class ImmersionTrackerService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordPrePlaybackMetadata({
|
||||
statsPath,
|
||||
aliases: buildMediaPathAliasCandidates(rawPath),
|
||||
displayTitle,
|
||||
libraryTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
parserSource: 'jellyfin',
|
||||
metadataJson: JSON.stringify({
|
||||
source: 'jellyfin',
|
||||
itemId: normalizeText(metadata.itemId) || null,
|
||||
itemTitle,
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
recordStreamPlaybackMetadata(metadata: StreamPlaybackMetadataInput): void {
|
||||
const rawPath = normalizeMediaPath(metadata.mediaPath);
|
||||
const statsPath = normalizeMediaPath(metadata.statsPath) || rawPath;
|
||||
if (!statsPath) {
|
||||
return;
|
||||
}
|
||||
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||
const displayTitle =
|
||||
normalizeText(metadata.displayTitle) || seriesTitle || deriveCanonicalTitle(statsPath);
|
||||
const libraryTitle = seriesTitle || displayTitle;
|
||||
if (!libraryTitle) {
|
||||
return;
|
||||
}
|
||||
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
|
||||
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
|
||||
|
||||
this.recordPrePlaybackMetadata({
|
||||
statsPath,
|
||||
aliases: rawPath ? buildMediaPathAliasCandidates(rawPath) : [],
|
||||
displayTitle,
|
||||
libraryTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
parserSource: 'anime-browser',
|
||||
metadataJson: JSON.stringify({
|
||||
source: 'anime-browser',
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the video row and its series link ahead of playback, so the session
|
||||
* mpv's path change starts already belongs to the right anime.
|
||||
*/
|
||||
private recordPrePlaybackMetadata(params: {
|
||||
statsPath: string;
|
||||
aliases: string[];
|
||||
displayTitle: string;
|
||||
libraryTitle: string;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
parserSource: string;
|
||||
metadataJson: string;
|
||||
}): void {
|
||||
for (const alias of params.aliases) {
|
||||
this.mediaPathAliases.set(alias, params.statsPath);
|
||||
}
|
||||
|
||||
const videoId = getOrCreateVideoRecord(
|
||||
this.db,
|
||||
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
|
||||
buildVideoKey(params.statsPath, SOURCE_TYPE_REMOTE),
|
||||
{
|
||||
canonicalTitle: displayTitle,
|
||||
canonicalTitle: params.displayTitle,
|
||||
sourcePath: null,
|
||||
sourceUrl: normalizedPath,
|
||||
sourceUrl: params.statsPath,
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
const previousLink = this.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { animeId: number | null } | null;
|
||||
const metadataJson = JSON.stringify({
|
||||
source: 'jellyfin',
|
||||
itemId: normalizeText(metadata.itemId) || null,
|
||||
itemTitle,
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: libraryTitle,
|
||||
canonicalTitle: libraryTitle,
|
||||
seasonScope: seasonNumber,
|
||||
parsedTitle: params.libraryTitle,
|
||||
canonicalTitle: params.libraryTitle,
|
||||
seasonScope: params.seasonNumber,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson,
|
||||
metadataJson: params.metadataJson,
|
||||
});
|
||||
linkVideoToAnimeRecord(this.db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: libraryTitle,
|
||||
parsedSeason: seasonNumber,
|
||||
parsedEpisode: episodeNumber,
|
||||
parserSource: 'jellyfin',
|
||||
parsedTitle: params.libraryTitle,
|
||||
parsedSeason: params.seasonNumber,
|
||||
parsedEpisode: params.episodeNumber,
|
||||
parserSource: params.parserSource,
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: metadataJson,
|
||||
parseMetadataJson: params.metadataJson,
|
||||
});
|
||||
|
||||
const hasLifetimeMedia = Boolean(
|
||||
@@ -1320,17 +1404,17 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
}
|
||||
|
||||
private hasJellyfinMetadata(videoId: number): boolean {
|
||||
private hasPrePlaybackMetadata(videoId: number): boolean {
|
||||
const row = this.db
|
||||
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { parserSource: string | null } | null;
|
||||
return row?.parserSource === 'jellyfin';
|
||||
return row?.parserSource !== null && PREPLAYBACK_PARSER_SOURCES.has(row?.parserSource ?? '');
|
||||
}
|
||||
|
||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||
const rawPath = normalizeMediaPath(mediaPath);
|
||||
const normalizedPath =
|
||||
buildJellyfinMediaPathAliasCandidates(rawPath)
|
||||
buildMediaPathAliasCandidates(rawPath)
|
||||
.map((alias) => this.mediaPathAliases.get(alias))
|
||||
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
|
||||
const normalizedTitle = normalizeText(mediaTitle);
|
||||
@@ -1380,7 +1464,7 @@ export class ImmersionTrackerService {
|
||||
if (youtubeVideoId) {
|
||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
|
||||
} else if (!this.hasPrePlaybackMetadata(sessionInfo.videoId)) {
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
}
|
||||
if (!youtubeVideoId) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user