From ec48f90cd4a682780fbeaeb07fd8381149dd104c Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 31 Jul 2026 17:16:49 -0700 Subject: [PATCH 01/50] feat(anime): add anime browser powered by Aniyomi extensions - Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached - Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config - Support searching all sources at once with streaming, per-source results and status - Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese - Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon - Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery) --- CHANGELOG.md | 22 +- changes/anime-browser.md | 15 + config.example.jsonc | 12 + docs-site/.vitepress/config.ts | 1 + docs-site/.vitepress/theme/mermaid-modal.css | 8 +- .../.vitepress/theme/status-line.test.ts | 4 +- docs-site/.vitepress/theme/tui-theme.css | 97 +- docs-site/anime-browser.md | 182 ++++ docs-site/aniskip-integration.md | 6 +- docs-site/changelog.md | 14 + docs-site/ipc-contracts.md | 20 +- docs-site/jimaku-integration.md | 34 +- docs-site/mpv-plugin.md | 28 +- docs-site/public/config.example.jsonc | 12 + docs-site/shortcuts.md | 36 +- docs-site/subtitle-sidebar.md | 30 +- docs-site/usage.md | 2 + docs-site/websocket-texthooker-api.md | 64 +- docs-site/youtube-integration.md | 10 +- docs/knowledge-base/quality.md | 36 +- launcher/commands/app-command.ts | 11 + launcher/commands/command-modules.test.ts | 4 + launcher/commands/playback-command.test.ts | 1 + launcher/config/args-normalizer.test.ts | 4 + launcher/config/args-normalizer.ts | 7 + launcher/config/cli-parser-builder.ts | 14 + launcher/jellyfin.test.ts | 1 + launcher/mpv.test.ts | 1 + launcher/types.ts | 1 + package.json | 3 +- scripts/preload-bundles.test.ts | 23 +- scripts/prepare-build-assets.mjs | 7 + src/anime-bridge/bridge-client.test.ts | 148 +++ src/anime-bridge/bridge-client.ts | 214 +++++ src/anime-bridge/extension-installer.test.ts | 149 +++ src/anime-bridge/extension-installer.ts | 66 ++ src/anime-bridge/extension-repo.test.ts | 177 ++++ src/anime-bridge/extension-repo.ts | 183 ++++ src/anime-bridge/extension-store.test.ts | 146 +++ src/anime-bridge/extension-store.ts | 117 +++ src/anime-bridge/headers.test.ts | 66 ++ src/anime-bridge/headers.ts | 54 ++ src/anime-bridge/media-url.test.ts | 69 ++ src/anime-bridge/media-url.ts | 74 ++ src/anime-bridge/mpv-playback.test.ts | 220 +++++ src/anime-bridge/mpv-playback.ts | 234 +++++ src/anime-bridge/multi-source-search.test.ts | 71 ++ src/anime-bridge/multi-source-search.ts | 85 ++ src/anime-bridge/preference-store.ts | 68 ++ src/anime-bridge/preferences.test.ts | 143 +++ src/anime-bridge/preferences.ts | 132 +++ src/anime-bridge/sidecar-bundle.test.ts | 117 +++ src/anime-bridge/sidecar-bundle.ts | 150 +++ src/anime-bridge/sidecar-process.ts | 104 ++ src/anime-bridge/types.ts | 101 ++ src/animeui/animeui.ts | 505 ++++++++++ src/animeui/dom.ts | 13 + src/animeui/extensions-panel.ts | 280 ++++++ src/animeui/format.test.ts | 60 ++ src/animeui/format.ts | 35 + src/animeui/index.html | 158 +++ src/animeui/preferences-fields.ts | 143 +++ src/animeui/search-progress.test.ts | 103 ++ src/animeui/search-progress.ts | 106 ++ src/animeui/style.css | 908 ++++++++++++++++++ src/cli/args.ts | 7 + src/cli/help.ts | 1 + src/config/definitions.ts | 2 + .../definitions/defaults-integrations.ts | 8 + .../definitions/options-integrations.ts | 21 + src/config/definitions/template-sections.ts | 9 + src/config/resolve/integrations.ts | 46 + src/core/services/app-lifecycle.test.ts | 1 + src/core/services/app-lifecycle.ts | 3 +- src/core/services/cli-command.test.ts | 5 + src/core/services/cli-command.ts | 5 + .../services/dock-icon-visibility.test.ts | 72 ++ src/core/services/dock-icon-visibility.ts | 49 + src/core/services/overlay-window.ts | 8 +- src/core/services/startup-bootstrap.test.ts | 1 + src/core/services/stats-window-runtime.ts | 6 +- src/main.ts | 101 ++ src/main/cli-runtime.ts | 2 + src/main/dependencies.ts | 2 + src/main/runtime/anime-bridge-installer.ts | 163 ++++ .../runtime/anime-browser-ipc-handlers.ts | 75 ++ src/main/runtime/anime-browser-runtime.ts | 529 ++++++++++ .../runtime/cli-command-context-deps.test.ts | 1 + src/main/runtime/cli-command-context-deps.ts | 2 + .../cli-command-context-factory.test.ts | 1 + .../cli-command-context-main-deps.test.ts | 1 + .../runtime/cli-command-context-main-deps.ts | 2 + src/main/runtime/cli-command-context.test.ts | 1 + src/main/runtime/cli-command-context.ts | 2 + .../composers/cli-startup-composer.test.ts | 1 + .../composers/jellyfin-runtime-composer.ts | 6 + .../runtime/first-run-setup-service.test.ts | 1 + src/main/runtime/setup-window-factory.ts | 16 + src/main/runtime/tray-main-actions.test.ts | 2 + src/main/runtime/tray-main-actions.ts | 5 + src/main/runtime/tray-main-deps.test.ts | 2 + src/main/runtime/tray-main-deps.ts | 3 + .../runtime/tray-runtime-handlers.test.ts | 1 + src/main/runtime/tray-runtime.test.ts | 8 + src/main/runtime/tray-runtime.ts | 5 + src/main/state.ts | 2 + src/preload-animeui.ts | 66 ++ src/shared/ipc/contracts.ts | 18 + src/types/anime-browser.ts | 204 ++++ src/types/config.ts | 7 + src/types/integrations.ts | 18 + 111 files changed, 7218 insertions(+), 182 deletions(-) create mode 100644 changes/anime-browser.md create mode 100644 docs-site/anime-browser.md create mode 100644 src/anime-bridge/bridge-client.test.ts create mode 100644 src/anime-bridge/bridge-client.ts create mode 100644 src/anime-bridge/extension-installer.test.ts create mode 100644 src/anime-bridge/extension-installer.ts create mode 100644 src/anime-bridge/extension-repo.test.ts create mode 100644 src/anime-bridge/extension-repo.ts create mode 100644 src/anime-bridge/extension-store.test.ts create mode 100644 src/anime-bridge/extension-store.ts create mode 100644 src/anime-bridge/headers.test.ts create mode 100644 src/anime-bridge/headers.ts create mode 100644 src/anime-bridge/media-url.test.ts create mode 100644 src/anime-bridge/media-url.ts create mode 100644 src/anime-bridge/mpv-playback.test.ts create mode 100644 src/anime-bridge/mpv-playback.ts create mode 100644 src/anime-bridge/multi-source-search.test.ts create mode 100644 src/anime-bridge/multi-source-search.ts create mode 100644 src/anime-bridge/preference-store.ts create mode 100644 src/anime-bridge/preferences.test.ts create mode 100644 src/anime-bridge/preferences.ts create mode 100644 src/anime-bridge/sidecar-bundle.test.ts create mode 100644 src/anime-bridge/sidecar-bundle.ts create mode 100644 src/anime-bridge/sidecar-process.ts create mode 100644 src/anime-bridge/types.ts create mode 100644 src/animeui/animeui.ts create mode 100644 src/animeui/dom.ts create mode 100644 src/animeui/extensions-panel.ts create mode 100644 src/animeui/format.test.ts create mode 100644 src/animeui/format.ts create mode 100644 src/animeui/index.html create mode 100644 src/animeui/preferences-fields.ts create mode 100644 src/animeui/search-progress.test.ts create mode 100644 src/animeui/search-progress.ts create mode 100644 src/animeui/style.css create mode 100644 src/core/services/dock-icon-visibility.test.ts create mode 100644 src/core/services/dock-icon-visibility.ts create mode 100644 src/main/runtime/anime-bridge-installer.ts create mode 100644 src/main/runtime/anime-browser-ipc-handlers.ts create mode 100644 src/main/runtime/anime-browser-runtime.ts create mode 100644 src/preload-animeui.ts create mode 100644 src/types/anime-browser.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a32fa520b..42db87fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixed + +- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and, on macOS, puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback. +- Anime Browser Playback Session: Launching a video from the anime browser now starts a regular SubMiner session (tray icon plus the on-demand overlay runtime), and in `subminer anime` standalone mode, closing the browser window during playback no longer quits the app and kills the stream. The window can be reopened from the tray while mpv keeps playing. + ## v0.19.3 (2026-08-13) ### Added @@ -23,7 +30,6 @@ - Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log. - ## v0.19.2 (2026-08-04) ### Changed @@ -54,6 +60,7 @@ ## v0.19.0 (2026-07-29) ### Added + - Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend. - Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too. - Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs. @@ -61,9 +68,11 @@ - TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv. ### Changed + - Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable. ### Fixed + - AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art. - Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility. - AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it. @@ -79,6 +88,7 @@ Internal changes ### Internal + - Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan. - Consolidated renderer modal state handling into a descriptor registry. - Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits. @@ -90,10 +100,12 @@ ## v0.18.0 (2026-07-10) ### Added + - Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently. - Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker. ### Changed + - Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room. - Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings. - Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting. @@ -101,6 +113,7 @@ - Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips. ### Fixed + - Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running. - Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words. - Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations. @@ -114,6 +127,7 @@ Internal changes ### Internal + - Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow. @@ -121,15 +135,18 @@ ## v0.17.2 (2026-06-28) ### Fixed + - YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes. - YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load. ## v0.17.1 (2026-06-27) ### Added + - YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download. ### Fixed + - Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters. - YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download. @@ -210,6 +227,7 @@ Internal changes ### Internal + - **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout. - **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API. - **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn. @@ -219,9 +237,11 @@ ## v0.15.2 (2026-06-02) ### Changed + - Yomitan: Updated the bundled Yomitan build to the latest vendored revision. ### Fixed + - Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries. - Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window. - Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click. diff --git a/changes/anime-browser.md b/changes/anime-browser.md new file mode 100644 index 000000000..b360a9ddd --- /dev/null +++ b/changes/anime-browser.md @@ -0,0 +1,15 @@ +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 `/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 source. +- 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. +- 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 — a fast source is on screen while a slow one is still resolving, with per-source progress in the status bar. Results are tagged with the source they came from, and a source that fails is named in the status bar instead of blanking the grid. +- 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. diff --git a/config.example.jsonc b/config.example.jsonc index 1408eab7b..f1971c4bb 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -611,6 +611,18 @@ } // 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 /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. diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 42641f325..170074635 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -326,6 +326,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' }, diff --git a/docs-site/.vitepress/theme/mermaid-modal.css b/docs-site/.vitepress/theme/mermaid-modal.css index d54e8d19d..225dda05d 100644 --- a/docs-site/.vitepress/theme/mermaid-modal.css +++ b/docs-site/.vitepress/theme/mermaid-modal.css @@ -45,7 +45,9 @@ border: 1px solid var(--vp-c-border); border-radius: 0; background: var(--vp-c-bg); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 24px 64px rgba(0, 0, 0, 0.2); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.3), + 0 24px 64px rgba(0, 0, 0, 0.2); overflow: hidden; } @@ -62,7 +64,9 @@ font-family: var(--tui-font-mono); font-size: 12px; cursor: pointer; - transition: border-color 180ms ease, color 180ms ease; + transition: + border-color 180ms ease, + color 180ms ease; } .mermaid-modal__close:hover { diff --git a/docs-site/.vitepress/theme/status-line.test.ts b/docs-site/.vitepress/theme/status-line.test.ts index 61317fd4f..ab0952823 100644 --- a/docs-site/.vitepress/theme/status-line.test.ts +++ b/docs-site/.vitepress/theme/status-line.test.ts @@ -10,7 +10,5 @@ test('status line file path formats version archive home without trailing slash' }); test('status line file path keeps normal docs routes as markdown files', () => { - expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe( - 'v/0.12.0/configuration.md', - ); + expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe('v/0.12.0/configuration.md'); }); diff --git a/docs-site/.vitepress/theme/tui-theme.css b/docs-site/.vitepress/theme/tui-theme.css index e9026ff34..f0dcf96c2 100644 --- a/docs-site/.vitepress/theme/tui-theme.css +++ b/docs-site/.vitepress/theme/tui-theme.css @@ -22,17 +22,8 @@ :root { --tui-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace; --tui-font-body: - 'Manrope Default', - 'M PLUS 1', - 'Manrope', - 'Noto Sans CJK JP', - 'Noto Sans JP', - 'Hiragino Kaku Gothic ProN', - 'Meiryo', - 'Yu Gothic', - 'Hiragino Sans', - system-ui, - sans-serif; + 'Manrope Default', 'M PLUS 1', 'Manrope', 'Noto Sans CJK JP', 'Noto Sans JP', + 'Hiragino Kaku Gothic ProN', 'Meiryo', 'Yu Gothic', 'Hiragino Sans', system-ui, sans-serif; --tui-transition: 180ms ease; /* Theme-specific values — overridden in .dark below */ @@ -96,8 +87,11 @@ button, .VPFeature, .VPNavBarMenuLink, .VPSidebarItem .text { - transition: color var(--tui-transition), background var(--tui-transition), - border-color var(--tui-transition), opacity var(--tui-transition); + transition: + color var(--tui-transition), + background var(--tui-transition), + border-color var(--tui-transition), + opacity var(--tui-transition); } /* === Nav bar === */ @@ -218,8 +212,7 @@ button, background: var(--vp-c-bg-soft); border: 1px solid var(--vp-c-divider); color: var(--vp-c-brand-1); - font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', - monospace; + font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', monospace; font-variant-ligatures: none; } @@ -228,8 +221,7 @@ button, border-radius: 0; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg-alt) !important; - font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', - monospace; + font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', monospace; font-variant-ligatures: none; } @@ -273,7 +265,9 @@ button, .vp-doc a { text-decoration: none; border-bottom: 1px solid var(--tui-link-underline); - transition: border-color var(--tui-transition), color var(--tui-transition); + transition: + border-color var(--tui-transition), + color var(--tui-transition); } .vp-doc a:hover { @@ -312,20 +306,45 @@ button, border-left-width: 1px; } -.vp-doc .custom-block.tip { border-color: var(--vp-c-brand-1); } -.vp-doc .custom-block.tip::before { content: '-- tip'; color: var(--vp-c-brand-1); } +.vp-doc .custom-block.tip { + border-color: var(--vp-c-brand-1); +} +.vp-doc .custom-block.tip::before { + content: '-- tip'; + color: var(--vp-c-brand-1); +} -.vp-doc .custom-block.info { border-color: var(--vp-c-brand-2); } -.vp-doc .custom-block.info::before { content: '-- info'; color: var(--vp-c-brand-2); } +.vp-doc .custom-block.info { + border-color: var(--vp-c-brand-2); +} +.vp-doc .custom-block.info::before { + content: '-- info'; + color: var(--vp-c-brand-2); +} -.vp-doc .custom-block.warning { border-color: var(--vp-c-warning-1); } -.vp-doc .custom-block.warning::before { content: '-- warning'; color: var(--vp-c-warning-1); } +.vp-doc .custom-block.warning { + border-color: var(--vp-c-warning-1); +} +.vp-doc .custom-block.warning::before { + content: '-- warning'; + color: var(--vp-c-warning-1); +} -.vp-doc .custom-block.danger { border-color: var(--vp-c-danger-1); } -.vp-doc .custom-block.danger::before { content: '-- danger'; color: var(--vp-c-danger-1); } +.vp-doc .custom-block.danger { + border-color: var(--vp-c-danger-1); +} +.vp-doc .custom-block.danger::before { + content: '-- danger'; + color: var(--vp-c-danger-1); +} -.vp-doc .custom-block.details { border-color: var(--vp-c-divider); } -.vp-doc .custom-block.details::before { content: '-- details'; color: var(--vp-c-text-2); } +.vp-doc .custom-block.details { + border-color: var(--vp-c-divider); +} +.vp-doc .custom-block.details::before { + content: '-- details'; + color: var(--vp-c-text-2); +} .vp-doc .custom-block .custom-block-title { font-family: var(--tui-font-mono); @@ -413,11 +432,15 @@ button, } @keyframes tui-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } } - /* === Statusline === */ .tui-statusline { position: fixed; @@ -457,7 +480,7 @@ button, margin-left: -12px; } -.tui-statusline__mode[data-mode="HOME"] { +.tui-statusline__mode[data-mode='HOME'] { background: var(--vp-c-brand-2); } @@ -561,7 +584,9 @@ body { .VPFeatures .VPFeature { border-radius: 8px !important; border: 1px solid var(--vp-c-divider) !important; - transition: border-color var(--tui-transition), background var(--tui-transition), + transition: + border-color var(--tui-transition), + background var(--tui-transition), transform var(--tui-transition); position: relative; overflow: hidden; @@ -672,11 +697,7 @@ body { transform: translateX(-50%); width: 600px; height: 400px; - background: radial-gradient( - ellipse at center, - var(--tui-hero-glow) 0%, - transparent 70% - ); + background: radial-gradient(ellipse at center, var(--tui-hero-glow) 0%, transparent 70%); pointer-events: none; z-index: -1; } diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md new file mode 100644 index 000000000..fbffd5fdb --- /dev/null +++ b/docs-site/anime-browser.md @@ -0,0 +1,182 @@ +# 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. + +### 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 `/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. + +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 `/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 `/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. + +## 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. It is verified against a +pinned SHA-256 before running, unpacked into `/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. + +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). + Other platforms are unsupported. + +## 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. + +### 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). + +[mes]: https://github.com/1Selxo/M-Extension-Server diff --git a/docs-site/aniskip-integration.md b/docs-site/aniskip-integration.md index fc9bc2307..a8115b08d 100644 --- a/docs-site/aniskip-integration.md +++ b/docs-site/aniskip-integration.md @@ -45,9 +45,9 @@ Results are cached per file for the app session; only definitive "no intro found You can trigger AniSkip actions from mpv script-messages: -| Command | Effect | -| ------- | ------ | -| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) | +| Command | Effect | +| ----------------------------------------- | ----------------------------------------------------------------------- | +| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) | | `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result | These are handled by the SubMiner app over the IPC socket. diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 335aa870e..7a417713c 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -54,6 +54,7 @@ ## v0.19.0 (2026-07-29) **Added** + - Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend. - Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too. - Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs. @@ -61,9 +62,11 @@ - TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv. **Changed** + - Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable. **Fixed** + - AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art. - Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility. - AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it. @@ -79,6 +82,7 @@ Internal changes **Internal** + - Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan. - Consolidated renderer modal state handling into a descriptor registry. - Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits. @@ -95,10 +99,12 @@

v0.18.0 (2026-07-10)

**Added** + - Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently. - Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker. **Changed** + - Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room. - Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings. - Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting. @@ -106,6 +112,7 @@ - Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips. **Fixed** + - Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running. - Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words. - Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations. @@ -119,6 +126,7 @@ Internal changes **Internal** + - Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow. @@ -131,15 +139,18 @@

v0.17.2 (2026-06-28)

**Fixed** + - YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes. - YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.

v0.17.1 (2026-06-27)

**Added** + - YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download. **Fixed** + - Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters. - YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download. @@ -225,6 +236,7 @@ Internal changes **Internal** + - **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout. - **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API. - **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn. @@ -239,9 +251,11 @@

v0.15.2 (2026-06-02)

**Changed** + - Yomitan: Updated the bundled Yomitan build to the latest vendored revision. **Fixed** + - Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries. - Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window. - Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click. diff --git a/docs-site/ipc-contracts.md b/docs-site/ipc-contracts.md index 44ce3a628..9054dacc5 100644 --- a/docs-site/ipc-contracts.md +++ b/docs-site/ipc-contracts.md @@ -38,7 +38,7 @@ flowchart TB ## Runtime Sockets -The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes. +The renderer↔main bridge above lives _inside_ the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes. - **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`. - **App control socket** (`/tmp/subminer-control--.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice. @@ -69,15 +69,15 @@ How these sockets are established during launch is covered in [Playback Startup ## Core Surfaces -| File | Role | -| --- | --- | -| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. | -| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. | -| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. | -| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. | -| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. | -| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. | -| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. | +| File | Role | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. | +| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. | +| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. | +| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. | +| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. | +| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. | +| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. | ## Contract Rules diff --git a/docs-site/jimaku-integration.md b/docs-site/jimaku-integration.md index de3875488..a103cb576 100644 --- a/docs-site/jimaku-integration.md +++ b/docs-site/jimaku-integration.md @@ -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", + }, } ``` diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md index 01112d8c6..7be12870f 100644 --- a/docs-site/mpv-plugin.md +++ b/docs-site/mpv-plugin.md @@ -31,20 +31,20 @@ input-ipc-server=\\.\pipe\subminer-socket The plugin reads options from `script-opts` with the `subminer-` prefix (for example `--script-opts=subminer-backend=hyprland`). Managed launches inject these automatically from your SubMiner config; the shipped `subminer.conf` is intentionally empty so command-line opts always win. -| Option | Default | Description | -| -------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------- | -| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) | -| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) | -| `texthooker_enabled` | `no` | Start the texthooker server with the overlay | -| `texthooker_port` | `5174` | Texthooker server port | -| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) | -| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) | -| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) | -| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts | -| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready | -| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway | -| `osd_messages` | `yes` | Show plugin OSD status messages | -| `log_level` | `info` | Plugin log verbosity | +| Option | Default | Description | +| ---------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------- | +| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) | +| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) | +| `texthooker_enabled` | `no` | Start the texthooker server with the overlay | +| `texthooker_port` | `5174` | Texthooker server port | +| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) | +| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) | +| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) | +| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts | +| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready | +| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway | +| `osd_messages` | `yes` | Show plugin OSD status messages | +| `log_level` | `info` | Plugin log verbosity | ## Keybindings diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 1408eab7b..f1971c4bb 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -611,6 +611,18 @@ } // 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 /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. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 645cd1440..0599313e8 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -12,10 +12,10 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi ## App-Wide Shortcuts -| Shortcut | Action | Scope | Configurable | -| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- | -| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` | -| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) | +| Shortcut | Action | Scope | Configurable | +| ------------- | ---------------------- | ---------------------------------------- | -------------------------------------- | +| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` | +| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) | ::: tip `Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed. @@ -75,21 +75,21 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle ## Subtitle & Feature Shortcuts -| Shortcut | Action | Config key | -| ------------------ | -------------------------------------------------------- | ------------------------------------------ | -| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | -| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` | -| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | -| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | -| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | -| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` | -| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | -| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | -| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | -| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` | +| Shortcut | Action | Config key | +| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | +| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` | +| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | +| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` | +| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | +| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | +| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | +| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) | -| `` ` `` | Toggle stats overlay | `stats.toggleKey` | -| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` | +| `` ` `` | Toggle stats overlay | `stats.toggleKey` | +| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` | `shortcuts.openAnimetosho` remains accepted as a deprecated alias for `shortcuts.openTsukihime`. The current name takes precedence when both are configured. diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md index e52030852..d342e1036 100644 --- a/docs-site/subtitle-sidebar.md +++ b/docs-site/subtitle-sidebar.md @@ -62,23 +62,23 @@ Styling lives under the `css` object, using CSS property names and CSS custom pr | `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list | | `autoScroll` | boolean | `true` | Keep the active cue in view during playback | -| `css` property | Default | Description | -| ------------------------------------------- | --------------------------- | ---------------------------- | -| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family | -| `color` | `#cad3f5` | Default cue text color | -| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color | -| `font-size` | `16px` | Base cue font size | -| `opacity` | `0.95` | Sidebar opacity between `0` and `1` | -| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width | -| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color | -| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color | -| `--subtitle-sidebar-active-background-color`| `rgba(138, 173, 244, 0.22)` | Active cue background color | -| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color | +| `css` property | Default | Description | +| -------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- | +| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family | +| `color` | `#cad3f5` | Default cue text color | +| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color | +| `font-size` | `16px` | Base cue font size | +| `opacity` | `0.95` | Sidebar opacity between `0` and `1` | +| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width | +| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color | +| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color | +| `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color | +| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color | ## Keyboard Shortcut -| Key | Action | Config key | -| --- | ----------------------- | ------------------------------ | -| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | +| Key | Action | Config key | +| --- | ----------------------- | --------------------------- | +| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | The toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. See [Keyboard Shortcuts](/shortcuts) for the full shortcut reference. diff --git a/docs-site/usage.md b/docs-site/usage.md index ad695fa43..c5356b4c5 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -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 ``` @@ -135,6 +136,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 diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md index 9756bf3e7..01e768be0 100644 --- a/docs-site/websocket-texthooker-api.md +++ b/docs-site/websocket-texthooker-api.md @@ -2,7 +2,7 @@ **Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools - a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients. -A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to. +A _texthooker_ is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to. SubMiner exposes a small set of local integration surfaces for browser tools, automation helpers, and mpv-driven workflows: @@ -15,12 +15,12 @@ This page documents those integration points and shows how to build custom consu ## Quick Reference -| Surface | Default | Purpose | -| --- | --- | --- | -| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream | -| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata | -| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config | -| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv | +| Surface | Default | Purpose | +| --------------------- | --------------------------- | -------------------------------------------------- | +| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream | +| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata | +| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config | +| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv | ## Enable and Configure the Services @@ -30,16 +30,16 @@ SubMiner's integration ports are configured in `config.jsonc`. All three service { "websocket": { "enabled": "auto", - "port": 6677 + "port": 6677, }, "annotationWebsocket": { "enabled": true, - "port": 6678 + "port": 6678, }, "texthooker": { "launchAtStartup": true, - "openBrowser": false - } + "openBrowser": false, + }, } ``` @@ -79,12 +79,12 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i #### Field reference -| Field | Type | Notes | -| --- | --- | --- | -| `version` | number | Current websocket payload version. Today this is `1`. | -| `text` | string | Raw subtitle text. | +| Field | Type | Notes | +| ---------- | ------ | ---------------------------------------------------------------------------------------------- | +| `version` | number | Current websocket payload version. Today this is `1`. | +| `text` | string | Raw subtitle text. | | `sentence` | string | Plain subtitle text with line breaks represented as `
`. No annotation spans or attributes. | -| `tokens` | array | Always empty on the basic subtitle websocket. | +| `tokens` | array | Always empty on the basic subtitle websocket. | ### 2. Annotation WebSocket @@ -129,22 +129,22 @@ On a tokenization cache miss, this stream first sends the cue as plain text with Each annotation token may include: -| Token field | Type | Notes | -| --- | --- | --- | -| `surface` | string | Display text for the token | -| `reading` | string | Kana reading when available | -| `headword` | string | Dictionary headword when available | -| `startPos` / `endPos` | number | Character offsets in the subtitle text | -| `partOfSpeech` | string | SubMiner token POS label | -| `isMerged` | boolean | Whether this token represents merged content | -| `isKnown` | boolean | Marked known by SubMiner's known-word logic | -| `isNPlusOneTarget` | boolean | True when the token is the sentence's N+1 target | -| `isNameMatch` | boolean | True for prioritized character-name matches | -| `frequencyRank` | number | Frequency rank when available | -| `jlptLevel` | string | JLPT level when available | -| `className` | string | CSS-ready class list derived from token state | -| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs | -| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs | +| Token field | Type | Notes | +| --------------------- | ---------------- | ------------------------------------------------ | +| `surface` | string | Display text for the token | +| `reading` | string | Kana reading when available | +| `headword` | string | Dictionary headword when available | +| `startPos` / `endPos` | number | Character offsets in the subtitle text | +| `partOfSpeech` | string | SubMiner token POS label | +| `isMerged` | boolean | Whether this token represents merged content | +| `isKnown` | boolean | Marked known by SubMiner's known-word logic | +| `isNPlusOneTarget` | boolean | True when the token is the sentence's N+1 target | +| `isNameMatch` | boolean | True for prioritized character-name matches | +| `frequencyRank` | number | Frequency rank when available | +| `jlptLevel` | string | JLPT level when available | +| `className` | string | CSS-ready class list derived from token state | +| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs | +| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs | ### 3. HTML markup conventions diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md index ac1dbdb97..71753d90d 100644 --- a/docs-site/youtube-integration.md +++ b/docs-site/youtube-integration.md @@ -110,8 +110,8 @@ Background cache downloads are capped at 720p by default (`youtube.mediaCache.ma } ``` -| Option | Type | Description | -| --------------------- | ---------- | ------------------------------------------------------------------------------------- | +| Option | Type | Description | +| --------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `primarySubLanguages` | `string[]` | Languages that count as a satisfactory primary subtitle (default `["ja", "jpn"]`). Used by the "primary subtitle missing" notification and by managed local/playlist subtitle selection. | YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track — `primarySubLanguages` does not change which YouTube track is auto-picked. @@ -130,11 +130,11 @@ YouTube secondary selection is fixed: SubMiner always tries an English track (ma } ``` -| Option | Type | Description | -| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Option | Type | Description | +| ----------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track for local/Jellyfin sidecar files. Default is empty (`[]`). Not used for YouTube. | | `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary sidecar track for local files (default: `false`). Not used for YouTube. | -| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) | +| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) | These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection. diff --git a/docs/knowledge-base/quality.md b/docs/knowledge-base/quality.md index df7d4ffe8..9fa5c1ab4 100644 --- a/docs/knowledge-base/quality.md +++ b/docs/knowledge-base/quality.md @@ -11,27 +11,27 @@ Grades are directional, not ceremonial. The point is to keep gaps visible. ## Product / Runtime Domains -| Area | Grade | Notes | -| --- | --- | --- | -| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear | -| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding | -| mpv plugin | B | modular, but Lua/runtime coupling still specialized | -| Overlay renderer | B | improved modularity; interaction complexity remains | -| Config system | A- | clear defaults/definitions split and good validation surface | -| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services | -| Internal docs system | B | new structure in place; needs habitual maintenance | -| Public docs site | B | strong user docs; must stay separate from internal KB | +| Area | Grade | Notes | +| --------------------------------------- | ----- | ------------------------------------------------------------------------ | +| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear | +| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding | +| mpv plugin | B | modular, but Lua/runtime coupling still specialized | +| Overlay renderer | B | improved modularity; interaction complexity remains | +| Config system | A- | clear defaults/definitions split and good validation surface | +| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services | +| Internal docs system | B | new structure in place; needs habitual maintenance | +| Public docs site | B | strong user docs; must stay separate from internal KB | ## Architectural Layers -| Layer | Grade | Notes | -| --- | --- | --- | -| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep | -| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt | -| `src/core/services/` | B+ | good extraction pattern; some domains remain broad | -| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense | -| `launcher/` | B | clear command boundaries | -| `docs/` internal KB | B | structure exists; enforcement now guards core rules | +| Layer | Grade | Notes | +| ------------------------------ | ----- | --------------------------------------------------------- | +| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep | +| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt | +| `src/core/services/` | B+ | good extraction pattern; some domains remain broad | +| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense | +| `launcher/` | B | clear command boundaries | +| `docs/` internal KB | B | structure exists; enforcement now guards core rules | ## Current Gaps diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts index a4bfdba2e..43d176463 100644 --- a/launcher/commands/app-command.ts +++ b/launcher/commands/app-command.ts @@ -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; diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 4ab0b7be0..39ee4657d 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -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'), }); diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index ffae2d8a4..e040db690 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -63,6 +63,7 @@ function createContext(): LauncherCommandContext { logsExport: false, version: false, settings: false, + animeBrowser: false, configPath: false, configShow: false, mpvIdle: false, diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index 64799d349..57083dd3a 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -120,6 +120,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { logLevel: 'warn', }, settingsInvocation: null, + animeInvocation: null, mpvInvocation: null, appInvocation: null, dictionaryTriggered: false, @@ -174,6 +175,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () => settingsInvocation: { logLevel: undefined, }, + animeInvocation: null, mpvInvocation: null, appInvocation: null, dictionaryTriggered: false, @@ -221,6 +223,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () => action: undefined, }, settingsInvocation: null, + animeInvocation: null, mpvInvocation: null, appInvocation: null, dictionaryTriggered: false, @@ -266,6 +269,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => { jellyfinInvocation: null, configInvocation: null, settingsInvocation: null, + animeInvocation: null, mpvInvocation: null, appInvocation: null, dictionaryTriggered: false, diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index a1568cd5a..2afad0514 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -170,6 +170,7 @@ export function createDefaultArgs( version: false, update: false, settings: false, + animeBrowser: false, configPath: false, configShow: false, mpvIdle: false, @@ -355,6 +356,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); diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index e531123d5..4b19a9da3 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -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; @@ -115,6 +116,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n 'doctor', 'config', 'settings', + 'anime', 'mpv', 'logs', 'dictionary', @@ -168,6 +170,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; @@ -456,6 +459,16 @@ export function parseCliPrograms( }; }); + commandProgram + .command('anime') + .description('Open the anime browser window') + .option('--log-level ', 'Log level') + .action((options: Record) => { + animeInvocation = { + logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, + }; + }); + commandProgram .command('mpv') .description('MPV helpers') @@ -510,6 +523,7 @@ export function parseCliPrograms( jellyfinInvocation, configInvocation, settingsInvocation, + animeInvocation, mpvInvocation, appInvocation, dictionaryTriggered, diff --git a/launcher/jellyfin.test.ts b/launcher/jellyfin.test.ts index 9054e767e..c31900006 100644 --- a/launcher/jellyfin.test.ts +++ b/launcher/jellyfin.test.ts @@ -57,6 +57,7 @@ function createArgs(): Args { logsExport: false, version: false, settings: false, + animeBrowser: false, configPath: false, configShow: false, mpvIdle: false, diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 57c3a3e98..4c2f8599c 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -627,6 +627,7 @@ function makeArgs(overrides: Partial = {}): Args { logsExport: false, version: false, settings: false, + animeBrowser: false, configPath: false, configShow: false, mpvIdle: false, diff --git a/launcher/types.ts b/launcher/types.ts index bfc82ea03..8507d2d42 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -152,6 +152,7 @@ export interface Args { version: boolean; update?: boolean; settings: boolean; + animeBrowser: boolean; configPath: boolean; configShow: boolean; mpvIdle: boolean; diff --git a/package.json b/package.json index 1385b1adc..6ec0ea911 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/preload-bundles.test.ts b/scripts/preload-bundles.test.ts index 95fd30a58..bc51d7716 100644 --- a/scripts/preload-bundles.test.ts +++ b/scripts/preload-bundles.test.ts @@ -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 }; - 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/); +}); diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs index fad72b3c0..0457d0262 100644 --- a/scripts/prepare-build-assets.mjs +++ b/scripts/prepare-build-assets.mjs @@ -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'); @@ -47,6 +49,10 @@ function copySyncUiAssets() { copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui'); } +function copyAnimeUiAssets() { + copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui'); +} + function fallbackToMacosSource() { copyFile(macosHelperSourcePath, macosHelperSourceCopyPath); process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`); @@ -90,6 +96,7 @@ function main() { copyRendererAssets(); copySettingsAssets(); copySyncUiAssets(); + copyAnimeUiAssets(); buildMacosHelper(); } diff --git a/src/anime-bridge/bridge-client.test.ts b/src/anime-bridge/bridge-client.test.ts new file mode 100644 index 000000000..62a75c6fe --- /dev/null +++ b/src/anime-bridge/bridge-client.test.ts @@ -0,0 +1,148 @@ +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 source = { apkBase64: 'QVBLLUJZVEVT', sourceId: 'source-1' }; + +interface Recorded { + url: string; + body: Record; +} + +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) : {}, + }; + 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 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, source.apkBase64); + 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, source.apkBase64); + assert.equal(calls[0]?.body.extensionId, undefined); + assert.equal(calls[1]?.body.data, undefined); + assert.equal(calls[1]?.body.extensionId, EXTENSION_ID); +}); + +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, source.apkBase64); + 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'); +}); diff --git a/src/anime-bridge/bridge-client.ts b/src/anime-bridge/bridge-client.ts new file mode 100644 index 000000000..a45988dc1 --- /dev/null +++ b/src/anime-bridge/bridge-client.ts @@ -0,0 +1,214 @@ +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 { + /** Base64-encoded extension APK. */ + apkBase64: 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; +} + +/** 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 extensionIds = new Map(); + + constructor(options: BridgeClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async getCapabilities(): Promise { + const response = await this.fetchImpl(`${this.baseUrl}/capabilities`); + 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(): Promise { + try { + const capabilities = await this.getCapabilities(); + return ( + capabilities.mangatanMihonBridge === 1 && + capabilities.sourceFactory === true && + capabilities.preferenceCallbacks === true + ); + } catch { + return false; + } + } + + async searchAnime( + source: BridgeSource, + query: string, + page = 1, + filterList: unknown[] = [], + ): Promise { + return this.call(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 { + return this.call(source, 'sourcesAnime', {}); + } + + /** The extension's own settings schema, with current values. */ + async getSourcePreferences(source: BridgeSource): Promise { + return this.call(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 { + return this.call(source, 'setPreferenceAnime', {}, changedPreferenceKey); + } + + /** Full metadata for one anime: description, cover art, genres, status. */ + async getAnimeDetails(source: BridgeSource, animeUrl: string): Promise { + return this.call(source, 'getDetailsAnime', { + animeData: { url: animeUrl }, + }); + } + + async getPopularAnime(source: BridgeSource, page = 1): Promise { + return this.call(source, 'getPopularAnime', { page }); + } + + async getEpisodeList(source: BridgeSource, animeUrl: string): Promise { + return this.call(source, 'getEpisodeList', { + animeData: { url: animeUrl }, + }); + } + + async getVideoList(source: BridgeSource, episodeUrl: string): Promise { + return this.call(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( + source: BridgeSource, + method: string, + extras: Record, + changedPreferenceKey?: string, + ): Promise { + const cacheKey = source.sourceId ?? source.apkBase64; + 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 post( + method: string, + extras: Record, + source: BridgeSource, + extensionId: string | undefined, + changedPreferenceKey?: string, + ): Promise { + const payload: Record = { + method, + ...extras, + preferences: this.buildPreferences(source, changedPreferenceKey), + ...(extensionId === undefined ? { data: source.apkBase64 } : { extensionId }), + }; + + return this.fetchImpl(`${this.baseUrl}/dalvik`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + }); + } +} + +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, + ); +} diff --git a/src/anime-bridge/extension-installer.test.ts b/src/anime-bridge/extension-installer.test.ts new file mode 100644 index 000000000..25f7ae7a7 --- /dev/null +++ b/src/anime-bridge/extension-installer.test.ts @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, 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 { + 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 = {}): 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('')), 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('404')); + + 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('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); +}); diff --git a/src/anime-bridge/extension-installer.ts b/src/anime-bridge/extension-installer.ts new file mode 100644 index 000000000..05060036c --- /dev/null +++ b/src/anime-bridge/extension-installer.ts @@ -0,0 +1,66 @@ +import { mkdir, 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; +} + +/** APKs are a few MB; anything far past that is not an extension. */ +const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; + +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 { + const fetchImpl = options.fetchImpl ?? fetch; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const response = await fetchImpl(options.extension.apkUrl); + 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 = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) { + throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`); + } + if (!looksLikeApk(bytes)) { + throw new Error(`${options.extension.name} did not download as an APK.`); + } + + await mkdir(options.extensionsDir, { recursive: true }); + const target = path.join(options.extensionsDir, extensionFileName(options.extension.pkg)); + await writeFile(target, bytes); + return target; +} + +/** Delete an installed extension. Missing files are treated as already gone. */ +export async function removeExtension(extensionsDir: string, pkg: string): Promise { + await rm(path.join(extensionsDir, extensionFileName(pkg)), { force: true }); +} diff --git a/src/anime-bridge/extension-repo.test.ts b/src/anime-bridge/extension-repo.test.ts new file mode 100644 index 000000000..a645f76ac --- /dev/null +++ b/src/anime-bridge/extension-repo.test.ts @@ -0,0 +1,177 @@ +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 = {}): Record { + 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('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('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', + ); +}); diff --git a/src/anime-bridge/extension-repo.ts b/src/anime-bridge/extension-repo.ts new file mode 100644 index 000000000..5639bfb2f --- /dev/null +++ b/src/anime-bridge/extension-repo.ts @@ -0,0 +1,183 @@ +/** + * 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'; + +/** + * 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) || apk.length === 0) 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; +} + +/** Fetch and parse one repository index. */ +export async function fetchRepoIndex( + indexUrl: string, + options: FetchRepoOptions = {}, +): Promise { + if (!isValidRepoUrl(indexUrl)) { + throw new Error(`Not a valid repository index URL: ${indexUrl}`); + } + const fetchImpl = options.fetchImpl ?? fetch; + const response = await fetchImpl(indexUrl.trim(), { + headers: { Accept: 'application/json' }, + ...(options.signal ? { signal: options.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 { + const failures: RepoFetchFailure[] = []; + const byPackage = new Map(); + + 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`; +} diff --git a/src/anime-bridge/extension-store.test.ts b/src/anime-bridge/extension-store.test.ts new file mode 100644 index 000000000..36dd424ca --- /dev/null +++ b/src/anime-bridge/extension-store.test.ts @@ -0,0 +1,146 @@ +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 { + listExtensionSources, + readInstalledExtensions, + toBridgeSource, + toInstalledExtensionViews, + type ExtensionSource, + type InstalledExtension, +} from './extension-store'; +import type { AnimeBridgeClient } from './bridge-client'; + +async function makeExtensionDir(files: Record): Promise { + 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: { apkBase64: string }) => Promise, +): AnimeBridgeClient { + return { listAnimeSources: impl } as unknown as AnimeBridgeClient; +} + +test('readInstalledExtensions reads apks and base64-encodes them', 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(Buffer.from(extensions[0]!.apkBase64, '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', + apkBase64: 'QQ==', + }; + assert.deepEqual(toBridgeSource(extension), { apkBase64: 'QQ==' }); + assert.deepEqual(toBridgeSource(extension, 'src-1'), { + apkBase64: 'QQ==', + sourceId: 'src-1', + }); +}); + +test('listExtensionSources flattens every source a factory apk provides', async () => { + const extensions: InstalledExtension[] = [ + { file: '/x/multi.apk', fallbackName: 'multi', apkBase64: 'QQ==' }, + ]; + 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 ids are normalized to strings so they can key UI state. + assert.equal(sources[0]?.id, '101'); + assert.equal(sources[0]?.name, 'Source One'); + assert.equal(sources[1]?.lang, 'ja'); +}); + +test('listExtensionSources falls back to the file name and a default language', async () => { + const extensions: InstalledExtension[] = [ + { file: '/x/my-ext.apk', fallbackName: 'my-ext', apkBase64: 'QQ==' }, + ]; + 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', apkBase64: 'QQ==' }, + ]); + assert.deepEqual(sources, []); +}); + +test('toInstalledExtensionViews names an extension after the sources it provides', () => { + const extensions: InstalledExtension[] = [ + { file: '/x/multi.apk', fallbackName: 'multi', apkBase64: 'QQ==' }, + ]; + const sources: ExtensionSource[] = [ + { id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' }, + { id: '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', apkBase64: 'QQ==' }, + ]; + + // 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', apkBase64: 'QQ==' }, + { file: '/x/good.apk', fallbackName: 'good', apkBase64: 'Qg==' }, + ]; + const failures: string[] = []; + const client = fakeClient(async (source) => { + if (source.apkBase64 === 'QQ==') 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'); +}); diff --git a/src/anime-bridge/extension-store.ts b/src/anime-bridge/extension-store.ts new file mode 100644 index 000000000..e1fcc7f01 --- /dev/null +++ b/src/anime-bridge/extension-store.ts @@ -0,0 +1,117 @@ +import { readdir, readFile } from 'node:fs/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; + apkBase64: string; +} + +export interface ExtensionSource { + /** Stable id: the bridge source id, which selects it inside a factory APK. */ + id: string; + name: string; + lang: string; + pkg: string; + file: string; +} + +/** Read every .apk in `directory`. A missing directory yields no extensions. */ +export async function readInstalledExtensions(directory: string): Promise { + 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); + const bytes = await readFile(file); + extensions.push({ + file, + fallbackName: entry.name.replace(/\.apk$/i, ''), + apkBase64: bytes.toString('base64'), + }); + } + return extensions; +} + +/** + * 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. */ +export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource { + return { apkBase64: extension.apkBase64, ...(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 { + const sources: ExtensionSource[] = []; + + for (const extension of extensions) { + try { + const descriptors = await client.listAnimeSources(toBridgeSource(extension)); + for (const descriptor of descriptors) { + const id = descriptor.id === undefined ? null : String(descriptor.id); + if (id === null || id.length === 0) continue; + sources.push({ + id, + name: descriptor.name?.trim() || extension.fallbackName, + lang: descriptor.lang ?? 'all', + pkg: extension.fallbackName, + file: extension.file, + }); + } + } catch (error) { + onError?.(extension, error); + } + } + + return sources; +} diff --git a/src/anime-bridge/headers.test.ts b/src/anime-bridge/headers.test.ts new file mode 100644 index 000000000..8528d3ffe --- /dev/null +++ b/src/anime-bridge/headers.test.ts @@ -0,0 +1,66 @@ +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 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, ''); +}); diff --git a/src/anime-bridge/headers.ts b/src/anime-bridge/headers.ts new file mode 100644 index 000000000..480b98e10 --- /dev/null +++ b/src/anime-bridge/headers.ts @@ -0,0 +1,54 @@ +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 { + const flat = headers?.['namesAndValues$okhttp']; + if (!Array.isArray(flat)) return {}; + + const parsed: Record = {}; + 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. + */ +export function toMpvHeaderFields(headers: Record): string { + return Object.entries(headers) + .map(([name, value]) => `${name}: ${value.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), + }; +} diff --git a/src/anime-bridge/media-url.test.ts b/src/anime-bridge/media-url.test.ts new file mode 100644 index 000000000..48e286adb --- /dev/null +++ b/src/anime-bridge/media-url.test.ts @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseAnimeStatus, resolveBridgeMediaUrl } from './media-url'; + +const BRIDGE = 'http://127.0.0.1:56037'; + +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'); +}); diff --git a/src/anime-bridge/media-url.ts b/src/anime-bridge/media-url.ts new file mode 100644 index 000000000..bf6f18461 --- /dev/null +++ b/src/anime-bridge/media-url.ts @@ -0,0 +1,74 @@ +/** + * 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(); +} + +/** 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'; + } +} diff --git a/src/anime-bridge/mpv-playback.test.ts b/src/anime-bridge/mpv-playback.test.ts new file mode 100644 index 000000000..51cc83521 --- /dev/null +++ b/src/anime-bridge/mpv-playback.test.ts @@ -0,0 +1,220 @@ +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 { + 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('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); +}); diff --git a/src/anime-bridge/mpv-playback.ts b/src/anime-bridge/mpv-playback.ts new file mode 100644 index 000000000..3fa05e816 --- /dev/null +++ b/src/anime-bridge/mpv-playback.ts @@ -0,0 +1,234 @@ +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; + +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. + */ +function escapeOptionValue(value: string): string { + return `%${value.length}%${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 = { + 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(); + 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; +} diff --git a/src/anime-bridge/multi-source-search.test.ts b/src/anime-bridge/multi-source-search.test.ts new file mode 100644 index 000000000..6dadadfd6 --- /dev/null +++ b/src/anime-bridge/multi-source-search.test.ts @@ -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 = { 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([]), []); +}); diff --git a/src/anime-bridge/multi-source-search.ts b/src/anime-bridge/multi-source-search.ts new file mode 100644 index 000000000..2c915953e --- /dev/null +++ b/src/anime-bridge/multi-source-search.ts @@ -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 { + /** 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( + sources: S[], + task: (source: S) => Promise, + concurrency: number = DEFAULT_CONCURRENCY, +): Promise> { + // 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 = sources.map(() => null); + let next = 0; + + const worker = async (): Promise => { + 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(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; +} diff --git a/src/anime-bridge/preference-store.ts b/src/anime-bridge/preference-store.ts new file mode 100644 index 000000000..30ca4d93c --- /dev/null +++ b/src/anime-bridge/preference-store.ts @@ -0,0 +1,68 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import type { BridgePreference } from './types'; + +/** + * Persists each source's preference array verbatim, keyed by 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 | null = null; + + constructor(file: string) { + this.file = file; + } + + private async load(): Promise> { + if (this.cache !== null) return this.cache; + try { + const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown; + this.cache = + parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + // Missing or corrupt file starts empty rather than blocking the browser. + this.cache = {}; + } + return this.cache; + } + + async get(sourceId: string): Promise { + const all = await this.load(); + return all[sourceId] ?? []; + } + + async set(sourceId: string, preferences: BridgePreference[]): Promise { + const all = await this.load(); + all[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 { + 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); + } + + private async persist(all: Record): Promise { + await mkdir(path.dirname(this.file), { recursive: true }); + await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 }); + } +} diff --git a/src/anime-bridge/preferences.test.ts b/src/anime-bridge/preferences.test.ts new file mode 100644 index 000000000..f62367a0c --- /dev/null +++ b/src/anime-bridge/preferences.test.ts @@ -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); +}); diff --git a/src/anime-bridge/preferences.ts b/src/anime-bridge/preferences.ts new file mode 100644 index 000000000..444e74926 --- /dev/null +++ b/src/anime-bridge/preferences.ts @@ -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; + +type WidgetName = keyof typeof WIDGETS; + +function widgetOf( + entry: BridgePreference, +): { name: WidgetName; body: Record } | 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 }; + } + } + 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}`); +} diff --git a/src/anime-bridge/sidecar-bundle.test.ts b/src/anime-bridge/sidecar-bundle.test.ts new file mode 100644 index 000000000..265d0ecd7 --- /dev/null +++ b/src/anime-bridge/sidecar-bundle.test.ts @@ -0,0 +1,117 @@ +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 { + 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); +}); + +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' }] }, + { + tag_name: 'v1.0.6.0', + assets: [ + { + name: 'macOS-arm64-bundle.zip', + browser_download_url: 'https://example.test/macOS-arm64-bundle.zip', + size: 133_058_560, + }, + ], + }, + ]; + + const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip'); + assert.equal(asset?.tagName, 'v1.0.6.0'); + assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip'); + assert.equal(asset?.sizeBytes, 133_058_560); +}); + +test('selectBundleAsset returns null when nothing matches', () => { + assert.equal(selectBundleAsset([{ tag_name: 'v1', 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('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('linux-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 macOS arm64 hash are the verified release', () => { + assert.equal(PINNED_BUNDLE_TAG, 'v1.0.6.0'); + assert.equal( + PINNED_BUNDLE_SHA256['macOS-arm64-bundle.zip'], + '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6', + ); +}); diff --git a/src/anime-bridge/sidecar-bundle.ts b/src/anime-bridge/sidecar-bundle.ts new file mode 100644 index 000000000..56cbf9005 --- /dev/null +++ b/src/anime-bridge/sidecar-bundle.ts @@ -0,0 +1,150 @@ +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. + */ + +export const BUNDLE_RELEASES_URL = + 'https://api.github.com/repos/1Selxo/M-Extension-Server/releases?page=1&per_page=10'; + +/** + * 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> = { + 'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6', +}; + +/** + * 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 { + 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 { + 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 { + 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 newest release carrying an asset for this platform. Releases are + * returned newest-first, and some (the iOS runtime) carry no desktop bundle. + */ +export function selectBundleAsset(releases: unknown, assetName: string): BundleAsset | null { + if (!Array.isArray(releases)) return null; + for (const release of releases as GithubRelease[]) { + 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; +} diff --git a/src/anime-bridge/sidecar-process.ts b/src/anime-bridge/sidecar-process.ts new file mode 100644 index 000000000..5123d8f93 --- /dev/null +++ b/src/anime-bridge/sidecar-process.ts @@ -0,0 +1,104 @@ +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; + +/** Ask the OS for a free loopback port, then hand it to the JVM. */ +export async function allocatePort(): Promise { + 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; + client: AnimeBridgeClient; + stop: () => Promise; +} + +export interface StartSidecarOptions { + binaries: BundleBinaries; + port?: number; + readyTimeoutMs?: 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 `, + * run from the JAR's own directory. + */ +export async function startSidecar(options: StartSidecarOptions): Promise { + 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; + child.once('exit', (code, signal) => { + exited = { code, signal }; + }); + + const stop = async (): Promise => { + if (exited !== 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) child.kill(); + }; + + const client = new AnimeBridgeClient({ baseUrl }); + const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS); + + while (Date.now() < deadline) { + 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}).`, + ); + } + if (await client.isReady()) return { baseUrl, port, client, stop }; + await new Promise((resolve) => setTimeout(resolve, READY_POLL_INTERVAL_MS)); + } + + await stop(); + throw new Error( + `Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`, + ); +} diff --git a/src/anime-bridge/types.ts b/src/anime-bridge/types.ts new file mode 100644 index 000000000..485bc2c04 --- /dev/null +++ b/src/anime-bridge/types.ts @@ -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/` 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; + subtitles: Array<{ url: string; lang: string }>; + audios: Array<{ url: string; lang: string }>; +} diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts new file mode 100644 index 000000000..f5a942e03 --- /dev/null +++ b/src/animeui/animeui.ts @@ -0,0 +1,505 @@ +import { describe, el } from './dom'; +import { sourceOptionLabel, summarizeSearch } from './format'; +import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress'; +import { createExtensionsPanel } from './extensions-panel'; +import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields'; +import { ALL_SOURCES_ID } from '../types/anime-browser'; +import type { + AnimeBrowserAPI, + AnimeBrowserBridgeState, + AnimeBrowserEntry, + AnimeBrowserEpisode, + AnimeBrowserSource, +} from '../types/anime-browser'; + +declare global { + interface Window { + animeBrowserAPI: AnimeBrowserAPI; + } +} + +const api = window.animeBrowserAPI; + +const searchForm = el('search-form'); +const searchInput = el('search-input'); +const searchButton = el('search-button'); +const sourceSelect = el('source-select'); +const grid = el('grid'); +const gridEmpty = el('grid-empty'); +const results = el('results'); +const detail = el('detail'); +const detailBack = el('detail-back'); +const detailCover = el('detail-cover'); +const detailTitle = el('detail-title'); +const detailChips = el('detail-chips'); +const detailDescription = el('detail-description'); +const episodes = el('episodes'); +const episodesCount = el('episodes-count'); +const banner = el('bridge-banner'); +const bannerMessage = el('bridge-message'); +const bannerMeter = el('bridge-meter'); +const bannerMeterFill = el('bridge-meter-fill'); +const statusMessage = el('status-message'); +const browseTab = el('tab-browse'); +const extensionsTab = el('tab-extensions'); +const settingsTab = el('tab-settings'); +const layout = el('layout'); +const extensionsPanel = el('extensions'); +const settingsPanel = el('settings'); +const settingsFields = el('settings-fields'); +const settingsTitle = el('settings-title'); + +/** The anime the detail page is showing, with the source that produced it. */ +let selectedAnime: { url: string; title: string; sourceId: string } | null = null; + +/** Where the results grid was scrolled to before the detail page covered it. */ +let resultsScrollTop = 0; + +/* ---------- 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 BRIDGE_LABELS: Record = { + 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; +} + +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 openDetail(entry); + }); + return card; +} + +function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void { + // Which source a cover came from only matters when they are mixed together. + const showSource = searchingAllSources(); + grid.replaceChildren(...entries.map((entry) => createCard(entry, showSource))); + + const empty = entries.length === 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(); + grid.append(...entries.map((entry) => createCard(entry, showSource))); +} + +function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string { + const value = episode.number ?? fallbackIndex; + return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1); +} + +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 sub = document.createElement('span'); + sub.className = 'cue-sub'; + sub.textContent = new Date(episode.uploadedAt).toISOString().slice(0, 10); + name.append(sub); + } + + button.append(cueIndex, name); + button.addEventListener('click', () => { + void playEpisode(button, episode); + }); + item.append(button); + return item; + }), + ); +} + +/** + * The detail page replaces the results grid rather than squeezing in beside + * it. The grid stays in the DOM with its scroll position remembered, so Back + * returns to the same results without re-running the search. + */ +async function openDetail(entry: AnimeBrowserEntry): Promise { + 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 { + // Always ask the entry's own source: after an all-sources search the + // picker's selection says nothing about where this cover came from. + const [details, episodeList] = await Promise.all([ + api.getDetails(entry.url, entry.sourceId), + api.getEpisodes(entry.url, entry.sourceId), + ]); + + 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) { + detailDescription.textContent = ''; + setStatus(describe(error), 'error'); + } +} + +function closeDetail(): void { + detail.classList.add('hidden'); + results.classList.remove('hidden'); + results.scrollTop = resultsScrollTop; + selectedAnime = null; +} + +async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpisode): Promise { + if (!selectedAnime) return; + + for (const other of episodes.querySelectorAll('.cue')) { + other.removeAttribute('data-state'); + } + button.dataset.state = 'loading'; + setStatus(`Resolving ${episode.name}…`); + + const result = await api.playEpisode({ + sourceId: selectedAnime.sourceId, + animeUrl: selectedAnime.url, + animeTitle: selectedAnime.title, + episodeUrl: episode.url, + episodeName: episode.name, + }); + + 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'); + } +} + +/* ---------- 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(); + +/** Orders runSearch calls so a slow search cannot finish over a newer one. */ +let searchRequest = 0; + +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) { + grid.replaceChildren(); + gridEmpty.classList.add('hidden'); + return; + } + if (applied.entries.length > 0) appendEntries(applied.entries); + if (!progress.done) { + setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info'); + } +}); + +async function runSearch(query: string): Promise { + const request = ++searchRequest; + // A new search means new results; leave the detail page for them. + if (!detail.classList.contains('hidden')) closeDetail(); + setStatus(query ? `Searching for “${query}”…` : 'Loading popular…'); + grid.replaceChildren(); + gridEmpty.classList.add('hidden'); + + try { + const result = query ? await api.search(query) : await api.getPopular(); + // A newer search owns the grid now; this one's result is history. + if (request !== searchRequest) return; + + // 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]; + renderEntries([], `${first?.sourceName}: ${first?.error}`); + setStatus(summarizeSearch(result), 'error'); + return; + } + + // The stream already filled the grid; only render from the result when no + // update arrived (covers a host without streaming wired up). + if (grid.childElementCount === 0 || result.entries.length === 0) { + renderEntries( + result.entries, + query ? `Nothing found for “${query}”.` : 'This source returned nothing.', + ); + } + setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info'); + } catch (error) { + if (request !== searchRequest) return; + renderEntries([], describe(error)); + setStatus(describe(error), 'error'); + } +} + +/* ---------- source settings ---------- */ + +async function openSettings(): Promise { + 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 { + 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 () => { + await api.selectSource(sourceSelect.value); + // Settings belong to the source, so reload them rather than showing stale fields. + if (currentView === 'settings') await openSettings(); + await runSearch(searchInput.value.trim()); + })(); +}); + +detailBack.addEventListener('click', closeDetail); + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !detail.classList.contains('hidden')) { + closeDetail(); + } +}); + +api.onBridgeState(renderBridgeState); + +void (async () => { + renderBridgeState({ stage: 'idle', progress: null, message: null }); + 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'); + } +})(); diff --git a/src/animeui/dom.ts b/src/animeui/dom.ts new file mode 100644 index 000000000..7a9785050 --- /dev/null +++ b/src/animeui/dom.ts @@ -0,0 +1,13 @@ +/** Small helpers shared by the anime browser's panels. */ + +export function el(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*/, ''); +} diff --git a/src/animeui/extensions-panel.ts b/src/animeui/extensions-panel.ts new file mode 100644 index 000000000..afb33bd39 --- /dev/null +++ b/src/animeui/extensions-panel.ts @@ -0,0 +1,280 @@ +import { describe, el } from './dom'; +import { describeInstalled } from './format'; +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; +} + +interface RowAction { + label: string; + primary?: boolean; + onClick: () => void | Promise; +} + +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('extensions-dir'); + const installedList = el('installed-list'); + const installedCount = el('installed-count'); + const availableList = el('extensions-list'); + const repoInput = el('repo-input'); + const repoAddButton = el('repo-add'); + const repoList = el('repo-list'); + + async function afterChange(extensionName: string, verb: string): Promise { + await refresh(); + await onSourcesChanged(); + setStatus(`${extensionName} ${verb}`, 'ok'); + } + + function renderInstalled( + installed: InstalledExtensionView[], + offeredPkgs: Set, + 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 () => { + await api.removeRepo(repoUrl); + await refresh(); + }, + }, + ], + }), + ), + ); + } + + function renderAvailable( + available: AvailableExtension[], + repoFailures: Array<{ name: string; error: string }>, + hasRepos: boolean, + ): void { + const rows: HTMLElement[] = repoFailures.map((failure) => + extensionRow({ name: failure.name, sub: failure.error, isError: true }), + ); + + for (const extension of available) { + rows.push( + extensionRow({ + name: extension.name, + sub: `${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( + 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 { + const snapshot = await api.getSnapshot(); + extensionsDirLabel.textContent = snapshot.extensionsDir; + renderRepos(snapshot.repos); + + const repoFailures: Array<{ name: string; error: string }> = []; + 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); + renderAvailable( + // Installed extensions have their own section; leaving them here too + // would list every one of them twice. + available.extensions.filter((extension) => !extension.installed), + repoFailures, + snapshot.repos.length > 0, + ); + } + + 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 }; +} diff --git a/src/animeui/format.test.ts b/src/animeui/format.test.ts new file mode 100644 index 000000000..5d7aa863f --- /dev/null +++ b/src/animeui/format.test.ts @@ -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', + ); +}); diff --git a/src/animeui/format.ts b/src/animeui/format.ts new file mode 100644 index 000000000..484e1c7b6 --- /dev/null +++ b/src/animeui/format.ts @@ -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(' · '); +} diff --git a/src/animeui/index.html b/src/animeui/index.html new file mode 100644 index 000000000..235651ab3 --- /dev/null +++ b/src/animeui/index.html @@ -0,0 +1,158 @@ + + + + + + SubMiner Anime + + + +
+
+
SubMiner
+
Anime
+
+ + + + + + +
+ + + + + + + +
+
+
+ +
+ + +
+ +
+ +
+ + + + diff --git a/src/animeui/preferences-fields.ts b/src/animeui/preferences-fields.ts new file mode 100644 index 000000000..579dba48b --- /dev/null +++ b/src/animeui/preferences-fields.ts @@ -0,0 +1,143 @@ +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; + +/** 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}`); +} + +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 => { + state.removeAttribute('data-tone'); + state.textContent = 'Saving…'; + try { + const refreshed = await commit(view.key, value); + state.dataset.tone = 'ok'; + state.textContent = 'Saved'; + 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; +} + +export function renderPreferences( + container: HTMLElement, + views: SourcePreferenceView[], + commit: PreferenceCommit, +): void { + 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); +} diff --git a/src/animeui/search-progress.test.ts b/src/animeui/search-progress.test.ts new file mode 100644 index 000000000..973a89642 --- /dev/null +++ b/src/animeui/search-progress.test.ts @@ -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', + ); +}); diff --git a/src/animeui/search-progress.ts b/src/animeui/search-progress.ts new file mode 100644 index 000000000..3ca615a93 --- /dev/null +++ b/src/animeui/search-progress.ts @@ -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}`; +} diff --git a/src/animeui/style.css b/src/animeui/style.css new file mode 100644 index 000000000..ae8f995af --- /dev/null +++ b/src/animeui/style.css @@ -0,0 +1,908 @@ +@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; + flex-direction: column; + gap: 3px; + flex: none; + min-width: 190px; +} + +.source-label { + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--faint); +} + +/* ---------- 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); +} + +/* ---------- 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; +} + +/* ---------- 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; + width: clamp(140px, 18vw, 232px); + aspect-ratio: 2 / 3; + object-fit: cover; + 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: 41px; + top: 6px; + bottom: 6px; + width: 1px; + background: var(--line); +} + +.cue { + position: relative; + display: grid; + grid-template-columns: 40px 1fr; + gap: 14px; + 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: 37px; + 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; + } +} + +/* ---------- 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; +} + +.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); +} diff --git a/src/cli/args.ts b/src/cli/args.ts index a16fa8d99..8677c9e4b 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -15,6 +15,7 @@ export interface CliArgs { yomitan: boolean; settings: boolean; syncWindow: boolean; + animeBrowser: boolean; setup: boolean; show: boolean; hide: boolean; @@ -136,6 +137,7 @@ export function parseArgs(argv: string[]): CliArgs { yomitan: false, settings: false, syncWindow: false, + animeBrowser: false, setup: false, show: false, hide: false, @@ -287,6 +289,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; @@ -570,6 +573,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.yomitan || args.settings || args.syncWindow || + args.animeBrowser || args.setup || args.show || args.hide || @@ -649,6 +653,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.yomitan && !args.settings && !args.syncWindow && + !args.animeBrowser && !args.setup && !args.show && !args.hide && @@ -721,6 +726,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.yomitan || args.settings || args.syncWindow || + args.animeBrowser || args.setup || args.copySubtitle || args.copySubtitleMultiple || @@ -779,6 +785,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean { !args.togglePrimarySubtitleBar && !args.settings && !args.syncWindow && + !args.animeBrowser && !args.show && !args.hide && !args.setup && diff --git a/src/cli/help.ts b/src/cli/help.ts index cb0e1e471..1adda277c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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} diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 0cdfc924e..4aa85b7fd 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -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, diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 788564be7..b7223388f 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -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: '', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 9f8c3e320..b49fb3989 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -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 /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', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 43b22c60f..186db4ced 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -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.'], diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 6324eaee1..e2cf859a1 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -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) { diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 5b17bfc56..23790413b 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -17,6 +17,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { yomitan: false, settings: false, syncWindow: false, + animeBrowser: false, setup: false, show: false, hide: false, diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index e71fc1386..34c427f11 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -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(); } diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 5d23f3dd4..81047e85e 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -22,6 +22,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { yomitan: false, settings: false, syncWindow: false, + animeBrowser: false, setup: false, show: false, hide: false, @@ -142,6 +143,9 @@ function createDeps(overrides: Partial = {}) { 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: () => {}, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 35b1a7e4b..066e84f10 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -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) { diff --git a/src/core/services/dock-icon-visibility.test.ts b/src/core/services/dock-icon-visibility.test.ts new file mode 100644 index 000000000..b43f70815 --- /dev/null +++ b/src/core/services/dock-icon-visibility.test.ts @@ -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); +}); diff --git a/src/core/services/dock-icon-visibility.ts b/src/core/services/dock-icon-visibility.ts new file mode 100644 index 000000000..af56aea88 --- /dev/null +++ b/src/core/services/dock-icon-visibility.ts @@ -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; + 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; +} diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 4a51d78bd..e074ed763 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -15,6 +15,7 @@ import { type HyprlandPlacementStatus, } from './hyprland-window-placement'; import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options'; +import { isDockIconRetained } from './dock-icon-visibility'; import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG, @@ -82,7 +83,12 @@ export function updateOverlayWindowBounds( export function ensureOverlayWindowLevel(window: BrowserWindow): void { if (process.platform === 'darwin') { window.setAlwaysOnTop(true, 'screen-saver', 1); - window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + // While a regular window (anime browser, ...) holds the Dock icon, skip the + // accessory process transform so the app stays in the Dock and Cmd+Tab. + window.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: isDockIconRetained(), + }); window.setFullScreenable(false); window.moveTop(); return; diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 787a15feb..e948e8dd6 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -17,6 +17,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { yomitan: false, settings: false, syncWindow: false, + animeBrowser: false, setup: false, show: false, hide: false, diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index 4129f04e7..5d1b85cbd 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -4,6 +4,7 @@ import type { MessageBoxSyncOptions, } from 'electron'; import type { WindowGeometry } from '../../types'; +import { isDockIconRetained } from './dock-icon-visibility'; const DEFAULT_STATS_WINDOW_WIDTH = 900; const DEFAULT_STATS_WINDOW_HEIGHT = 700; @@ -125,7 +126,10 @@ export function promoteStatsWindowLevel( ): void { if (platform === 'darwin') { window.setAlwaysOnTop(true, 'screen-saver', 2); - window.setVisibleOnAllWorkspaces?.(true, { visibleOnFullScreen: true }); + window.setVisibleOnAllWorkspaces?.(true, { + visibleOnFullScreen: true, + skipTransformProcessType: isDockIconRetained(), + }); window.setFullScreenable?.(false); window.moveTop(); return; diff --git a/src/main.ts b/src/main.ts index 990e9f236..3127398fe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -425,6 +425,7 @@ import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; +import { releaseDockIcon, retainDockIcon } from './core/services/dock-icon-visibility'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions'; import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; @@ -532,7 +533,11 @@ import { createCreateFirstRunSetupWindowHandler, createCreateJellyfinSetupWindowHandler, createCreateSyncUiWindowHandler, + createCreateAnimeBrowserWindowHandler, } from './main/runtime/setup-window-factory'; +import { createAnimeBrowserRuntime } from './main/runtime/anime-browser-runtime'; +import { registerAnimeBrowserIpcHandlers } from './main/runtime/anime-browser-ipc-handlers'; +import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer'; import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime'; import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window'; import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime'; @@ -2954,6 +2959,7 @@ const { runJellyfinCommand, openJellyfinSetupWindow, getJellyfinClientInfo, + ensureMpvConnectedForPlayback, } = composeJellyfinRuntimeHandlers({ getResolvedJellyfinConfigMainDeps: { getResolvedConfig: () => configService.getConfig(), @@ -3247,6 +3253,99 @@ const { }, }); +const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions'); + +function resolveAnimeExtensionsDir(): string { + const configured = configService.getConfig().anime?.extensionsDir?.trim(); + return configured && configured.length > 0 ? configured : DEFAULT_ANIME_EXTENSIONS_DIR; +} + +const animeBrowserRuntime = createAnimeBrowserRuntime({ + extensionsDir: () => resolveAnimeExtensionsDir(), + repos: () => configService.getConfig().anime?.repos ?? [], + setRepos: (repos) => { + configService.patchRawConfig({ anime: { repos } }); + }, + preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined, + preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'), + ensureBinaries: (onProgress) => + ensureBridgeBinaries({ + installDir: path.join(USER_DATA_PATH, 'anime-bridge'), + onProgress, + }), + sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), + ensureMpvConnected: () => ensureMpvConnectedForPlayback(), + showVisibleOverlay: () => { + // Launching a video turns a browse-only instance into a regular SubMiner + // playback session: tray icon plus the overlay runtime that + // setVisibleOverlayVisible initializes on demand. + ensureTrayHandler(); + setVisibleOverlayVisible(true); + }, + showMpvOsd: (text) => + overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }), + onBridgeState: (state) => { + const window = appState.animeBrowserWindow; + if (window && !window.isDestroyed()) { + window.webContents.send(IPC_CHANNELS.event.animeBrowserBridgeState, state); + } + }, + onSearchUpdate: (update) => { + const window = appState.animeBrowserWindow; + if (window && !window.isDestroyed()) { + window.webContents.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update); + } + }, + log: (message) => logger.info(message), +}); + +registerAnimeBrowserIpcHandlers({ ipcMain, runtime: animeBrowserRuntime }); + +let animeBrowserDockIconRetained = false; +const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({ + getSettingsWindow: () => appState.animeBrowserWindow, + setSettingsWindow: (window) => { + appState.animeBrowserWindow = window as BrowserWindow | null; + }, + createSettingsWindow: createCreateAnimeBrowserWindowHandler({ + createBrowserWindow: (options) => new BrowserWindow(options), + preloadPath: path.join(__dirname, 'preload-animeui.js'), + }), + settingsHtmlPath: path.join(__dirname, 'animeui', 'index.html'), + promoteSettingsWindowAboveOverlay: (window) => + promoteSettingsWindowAboveOverlay(window as BrowserWindow), + onClosed: () => { + animeBrowserDockIconRetained = false; + releaseDockIcon({ + dock: app.dock, + shouldRehide: () => { + const mainWindow = overlayManager.getMainWindow(); + return Boolean(mainWindow && !mainWindow.isDestroyed()); + }, + }); + // The bridge holds the stream-proxy tokens mpv is playing through, so it + // must outlive the window. Only tear it down when the window was the + // app's whole reason to be running — and once a video has launched, the + // instance has become a regular playback session (tray, overlay, mpv + // stream), so closing the browser must not kill it either. + if (appState.initialArgs?.animeBrowser && !appState.mpvClient?.connected) { + void animeBrowserRuntime.dispose().finally(() => requestAppQuit()); + } + }, + log: (message) => logger.error(message), +}); +const openAnimeBrowserWindowHandler = (): boolean => { + const opened = openAnimeBrowserWindowBase(); + if (opened) { + if (!animeBrowserDockIconRetained) { + animeBrowserDockIconRetained = true; + retainDockIcon({ dock: app.dock }); + } + ensureTrayHandler(); + } + return opened; +}; + const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({ getSetupWindow: () => appState.firstRunSetupWindow, }); @@ -5940,6 +6039,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => configSettingsRuntime.openWindow(), openSyncUiWindow: () => openSyncUiWindowHandler(), + openAnimeBrowserWindow: () => openAnimeBrowserWindowHandler(), cycleSecondarySubMode: () => cycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), @@ -6193,6 +6293,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => configSettingsRuntime.openWindow(), openSyncUiWindow: () => openSyncUiWindowHandler(), + openAnimeBrowserWindow: () => openAnimeBrowserWindowHandler(), exportLogs: () => { void exportLogsFromTray(); }, diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 2da17bb83..4de03b006 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -51,6 +51,7 @@ export interface CliCommandRuntimeServiceContext { openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; + openAnimeBrowserWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -137,6 +138,7 @@ function createCliCommandDepsFromContext( openYomitanSettings: context.openYomitanSettings, openConfigSettingsWindow: context.openConfigSettingsWindow, openSyncUiWindow: context.openSyncUiWindow, + openAnimeBrowserWindow: context.openAnimeBrowserWindow, cycleSecondarySubMode: context.cycleSecondarySubMode, openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, printHelp: context.printHelp, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index cbbe4bebe..00c0255cc 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -212,6 +212,7 @@ export interface CliCommandRuntimeServiceDepsParams { openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow']; openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow']; + openAnimeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openAnimeBrowserWindow']; cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode']; openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette']; printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp']; @@ -418,6 +419,7 @@ export function createCliCommandRuntimeServiceDeps( openYomitanSettings: params.ui.openYomitanSettings, openConfigSettingsWindow: params.ui.openConfigSettingsWindow, openSyncUiWindow: params.ui.openSyncUiWindow, + openAnimeBrowserWindow: params.ui.openAnimeBrowserWindow, cycleSecondarySubMode: params.ui.cycleSecondarySubMode, openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette, printHelp: params.ui.printHelp, diff --git a/src/main/runtime/anime-bridge-installer.ts b/src/main/runtime/anime-bridge-installer.ts new file mode 100644 index 000000000..1703e5752 --- /dev/null +++ b/src/main/runtime/anime-bridge-installer.ts @@ -0,0 +1,163 @@ +import { spawn } from 'node:child_process'; +import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + BUNDLE_RELEASES_URL, + findBundleBinaries, + resolveBundleAssetName, + selectBundleAsset, + verifyPinnedBundle, + type BundleBinaries, +} from '../../anime-bridge/sidecar-bundle'; + +/** + * Downloads and unpacks the M-Extension-Server bundle that runs Aniyomi + * extension APKs. The bundle ships its own JRE, so no system Java is needed. + */ + +export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extracting'; + +export interface InstallProgress { + stage: InstallStage; + /** 0-1 during download, otherwise null. */ + progress: number | null; +} + +export interface EnsureBridgeOptions { + /** Directory the bundle is unpacked into, e.g. `/anime-bridge`. */ + installDir: string; + platform?: string; + arch?: string; + fetchImpl?: typeof fetch; + onProgress?: (progress: InstallProgress) => void; +} + +/** Extract a zip without adding a dependency, mirroring scripts/build-yomitan.mjs. */ +async function extractZip(zipPath: string, targetDir: string): Promise { + const attempts: Array<[string, string[]]> = [ + ['unzip', ['-qo', zipPath, '-d', targetDir]], + // bsdtar ships with macOS and Windows 10+ and reads zip archives. + ['tar', ['-xf', zipPath, '-C', targetDir]], + ]; + + let lastError = ''; + for (const [command, args] of attempts) { + const result = await runCommand(command, args); + if (result.ok) return; + lastError = result.error; + } + throw new Error(`Could not extract the anime bridge bundle. ${lastError}`); +} + +function runCommand( + command: string, + args: string[], +): Promise<{ ok: true } | { ok: false; error: string }> { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once('error', (error) => resolve({ ok: false, error: `${command}: ${error.message}` })); + child.once('exit', (code) => { + if (code === 0) resolve({ ok: true }); + else resolve({ ok: false, error: `${command} exited ${code}. ${stderr.trim()}` }); + }); + }); +} + +async function downloadWithProgress( + response: Response, + onProgress: ((fraction: number) => void) | undefined, +): Promise { + const declared = Number(response.headers.get('content-length') ?? '0'); + const reader = response.body?.getReader(); + if (!reader) return new Uint8Array(await response.arrayBuffer()); + + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + received += value.length; + if (declared > 0) onProgress?.(Math.min(1, received / declared)); + } + } + + const merged = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + return merged; +} + +/** + * Return the bundle's java + jar paths, downloading the release first if the + * install directory does not already hold a usable copy. + */ +export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promise { + const existing = await findBundleBinaries(options.installDir); + if (existing) return existing; + + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const fetchImpl = options.fetchImpl ?? fetch; + + const assetName = resolveBundleAssetName(platform, arch); + if (assetName === null) { + throw new Error( + `No anime bridge build is published for ${platform}/${arch}. ` + + 'Supported: macOS (arm64, x64), Linux (x64), Windows (x64).', + ); + } + + options.onProgress?.({ stage: 'locating', progress: null }); + const releasesResponse = await fetchImpl(BUNDLE_RELEASES_URL, { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (!releasesResponse.ok) { + throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`); + } + const asset = selectBundleAsset(await releasesResponse.json(), assetName); + if (asset === null) { + throw new Error(`No published anime bridge release contains ${assetName}.`); + } + + options.onProgress?.({ stage: 'downloading', progress: 0 }); + const downloadResponse = await fetchImpl(asset.downloadUrl); + if (!downloadResponse.ok) { + throw new Error(`Downloading the anime bridge failed (${downloadResponse.status}).`); + } + const bytes = await downloadWithProgress(downloadResponse, (fraction) => + options.onProgress?.({ stage: 'downloading', progress: fraction }), + ); + + options.onProgress?.({ stage: 'verifying', progress: null }); + const verification = verifyPinnedBundle(assetName, bytes); + if (!verification.ok) throw new Error(verification.reason); + + options.onProgress?.({ stage: 'extracting', progress: null }); + await mkdir(options.installDir, { recursive: true }); + const zipPath = path.join(options.installDir, assetName); + await writeFile(zipPath, bytes); + try { + await extractZip(zipPath, options.installDir); + } finally { + await rm(zipPath, { force: true }); + } + + const binaries = await findBundleBinaries(options.installDir); + if (!binaries) { + throw new Error('The anime bridge bundle unpacked without a java runtime or server jar.'); + } + // Some extractors drop the executable bit; restore it rather than failing at spawn. + if (platform !== 'win32') { + await chmod(binaries.javaPath, 0o755).catch(() => undefined); + } + return binaries; +} diff --git a/src/main/runtime/anime-browser-ipc-handlers.ts b/src/main/runtime/anime-browser-ipc-handlers.ts new file mode 100644 index 000000000..aaa0b1e3a --- /dev/null +++ b/src/main/runtime/anime-browser-ipc-handlers.ts @@ -0,0 +1,75 @@ +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import type { AnimeBrowserRuntime } from './anime-browser-runtime'; +import type { AnimeBrowserPlayRequest } from '../../types/anime-browser'; + +export interface AnimeBrowserIpcDeps { + // Structurally typed so tests can pass a fake without importing Electron. + ipcMain: { + handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown; + }; + runtime: AnimeBrowserRuntime; +} + +/** + * Bridge the renderer to the anime runtime. Page arguments arrive as `unknown` + * from the renderer, so they are coerced here rather than trusted. + */ +export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void { + const channels = IPC_CHANNELS.request; + const { runtime } = deps; + const handle = ( + channel: string, + listener: (event: unknown, ...args: unknown[]) => unknown, + ): void => { + deps.ipcMain.handle(channel, listener); + }; + + handle(channels.animeBrowserGetSnapshot, () => runtime.getSnapshot()); + handle(channels.animeBrowserEnsureBridge, () => runtime.ensureBridge()); + handle(channels.animeBrowserSelectSource, (_event, sourceId) => + runtime.selectSource(String(sourceId)), + ); + handle(channels.animeBrowserSearch, (_event, query, page) => + runtime.search(String(query ?? ''), toPage(page)), + ); + handle(channels.animeBrowserGetPopular, (_event, page) => runtime.getPopular(toPage(page))); + handle(channels.animeBrowserGetDetails, (_event, animeUrl, sourceId) => + runtime.getDetails(String(animeUrl), toOptionalId(sourceId)), + ); + handle(channels.animeBrowserGetEpisodes, (_event, animeUrl, sourceId) => + runtime.getEpisodes(String(animeUrl), toOptionalId(sourceId)), + ); + handle(channels.animeBrowserListAvailableExtensions, () => runtime.listAvailableExtensions()); + handle(channels.animeBrowserInstallExtension, (_event, pkg) => + runtime.installExtension(String(pkg)), + ); + handle(channels.animeBrowserRemoveExtension, (_event, pkg) => + runtime.removeExtension(String(pkg)), + ); + handle(channels.animeBrowserRescanExtensions, () => runtime.rescanExtensions()); + handle(channels.animeBrowserAddRepo, (_event, url) => runtime.addRepo(String(url))); + handle(channels.animeBrowserRemoveRepo, (_event, url) => runtime.removeRepo(String(url))); + handle(channels.animeBrowserPlayEpisode, (_event, request) => + runtime.playEpisode(request as AnimeBrowserPlayRequest), + ); + handle(channels.animeBrowserGetPreferences, (_event, sourceId) => + runtime.getPreferences(String(sourceId)), + ); + handle(channels.animeBrowserSetPreference, (_event, sourceId, key, value) => + runtime.setPreference(String(sourceId), String(key), value as string | string[] | boolean), + ); +} + +/** + * A source id the renderer may omit. Absent means "use the current selection", + * so an empty value must stay undefined rather than becoming the string "". + */ +function toOptionalId(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** Bridge pages are 1-based; anything unusable falls back to the first page. */ +function toPage(value: unknown): number { + const page = Number(value); + return Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1; +} diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts new file mode 100644 index 000000000..e250b736f --- /dev/null +++ b/src/main/runtime/anime-browser-runtime.ts @@ -0,0 +1,529 @@ +import { AnimeBridgeClient } from '../../anime-bridge/bridge-client'; +import { resolveStream } from '../../anime-bridge/headers'; +import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url'; +import { + buildPlaybackCommands, + buildTrackCommands, + selectPreferredStream, +} from '../../anime-bridge/mpv-playback'; +import { + listExtensionSources, + readInstalledExtensions, + toBridgeSource, + toInstalledExtensionViews, + type ExtensionSource, + type InstalledExtension, +} from '../../anime-bridge/extension-store'; +import { interleave, mapSourcesConcurrently } from '../../anime-bridge/multi-source-search'; +import { startSidecar, type SidecarHandle } from '../../anime-bridge/sidecar-process'; +import { + fetchRepoCatalogue, + isValidRepoUrl, + type RepoExtension, +} from '../../anime-bridge/extension-repo'; +import { + installExtension, + removeExtension as removeExtensionFile, +} from '../../anime-bridge/extension-installer'; +import { PreferenceStore } from '../../anime-bridge/preference-store'; +import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/preferences'; +import type { SourcePreferenceView } from '../../anime-bridge/preferences'; +import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle'; +import type { InstallProgress } from './anime-bridge-installer'; +import { ALL_SOURCES_ID } from '../../types/anime-browser'; +import type { + AnimeBrowserBridgeState, + AnimeBrowserDetails, + AnimeBrowserEntry, + AnimeBrowserEpisode, + AnimeBrowserPlayRequest, + AnimeBrowserPlayResult, + AnimeBrowserSearchResult, + AnimeBrowserSearchUpdate, + AnimeBrowserSnapshot, + AvailableExtensionsResult, + ExtensionLoadFailure, +} from '../../types/anime-browser'; +import type { BridgeAnimePage } from '../../anime-bridge/types'; + +export interface AnimeBrowserRuntimeDeps { + /** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */ + extensionsDir: () => string; + /** Configured repository index URLs. Empty unless the user added one. */ + repos: () => string[]; + /** Persists the repository list. Config stays the source of truth. */ + setRepos: (repos: string[]) => void; + /** JSON file holding each source's saved preference values. */ + preferencesFile: string; + ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise; + /** Sends mpv an IPC command; same transport the Jellyfin path uses. */ + sendMpvCommand: (command: Array) => void; + /** Brings mpv up if it is not already connected. Resolves false on failure. */ + ensureMpvConnected: () => Promise; + showMpvOsd?: (message: string) => void; + showVisibleOverlay?: () => void; + /** Lets tests drive the pause between `loadfile` and the track commands. */ + wait?: (ms: number) => Promise; + onBridgeState: (state: AnimeBrowserBridgeState) => void; + /** + * Streams per-source progress while a search invoke is pending. Optional so + * a host that has no window to push to can leave it out. + */ + onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void; + preferredQuality?: () => string | undefined; + log: (message: string) => void; +} + +const IDLE_STATE: AnimeBrowserBridgeState = { stage: 'idle', progress: null, message: null }; + +/** How long to let `loadfile` settle before adding external tracks. */ +const TRACK_ATTACH_DELAY_MS = 300; + +export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { + let bridgeState: AnimeBrowserBridgeState = IDLE_STATE; + let sidecar: SidecarHandle | null = null; + let starting: Promise | null = null; + let extensions: InstalledExtension[] = []; + let sources: ExtensionSource[] = []; + let selectedSourceId: string | null = null; + let loadFailures: ExtensionLoadFailure[] = []; + // Monotonic; identifies the newest browse so stale ones stop emitting. + let searchToken = 0; + const preferenceStore = new PreferenceStore(deps.preferencesFile); + const wait = + deps.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + function setState(state: AnimeBrowserBridgeState): void { + bridgeState = state; + deps.onBridgeState(state); + } + + async function sourceFor(sourceId: string) { + const source = sources.find((candidate) => candidate.id === sourceId); + if (!source) throw new Error('That source is no longer installed. Rescan and try again.'); + const extension = extensions.find((candidate) => candidate.file === source.file); + if (!extension) throw new Error(`Extension file missing for ${source.name}.`); + // Saved values ride along on every call; the extension is stateless per request. + const saved = await preferenceStore.get(source.id); + return { ...toBridgeSource(extension, source.id), preferences: saved }; + } + + function requireBridge(): { client: AnimeBridgeClient; baseUrl: string } { + if (!sidecar) throw new Error('The anime bridge is not running yet.'); + return { client: sidecar.client, baseUrl: sidecar.baseUrl }; + } + + async function startBridge(): Promise { + const binaries = await deps.ensureBinaries((progress) => + setState({ stage: progress.stage, progress: progress.progress, message: null }), + ); + + setState({ stage: 'starting', progress: null, message: null }); + const handle = await startSidecar({ + binaries, + onLog: (line) => deps.log(`[anime-bridge] ${line}`), + }); + sidecar = handle; + + await scanExtensions(handle); + return handle; + } + + /** + * Re-read the extensions directory and ask the bridge what each APK provides. + * Called on start and after any install or removal. + */ + async function scanExtensions(handle: SidecarHandle): Promise { + const directory = deps.extensionsDir(); + loadFailures = []; + extensions = await readInstalledExtensions(directory); + sources = await listExtensionSources(handle.client, extensions, (extension, error) => { + const message = describeError(error); + loadFailures.push({ pkg: extension.fallbackName, error: message }); + deps.log(`[anime-bridge] extension ${extension.fallbackName} failed to load: ${message}`); + }); + + // Keep the current selection if it survived the rescan. "All sources" + // survives as long as anything is installed. + const keptAll = selectedSourceId === ALL_SOURCES_ID && sources.length > 0; + if (!keptAll && !sources.some((source) => source.id === selectedSourceId)) { + selectedSourceId = sources[0]?.id ?? null; + } + + setState({ + stage: 'ready', + progress: null, + message: + sources.length === 0 + ? `No anime extensions installed. Add a repository or put .apk files in ${directory}.` + : null, + }); + } + + async function ensureBridge(): Promise { + if (sidecar) return bridgeState; + // Collapse concurrent callers onto one start; the UI calls this eagerly. + if (!starting) { + starting = startBridge().catch((error: unknown) => { + setState({ stage: 'failed', progress: null, message: describeError(error) }); + starting = null; + throw error; + }); + } + try { + await starting; + } catch { + return bridgeState; + } + return bridgeState; + } + + async function installExtensionFrom(extension: RepoExtension): Promise { + await installExtension({ extensionsDir: deps.extensionsDir(), extension }); + if (sidecar) await scanExtensions(sidecar); + } + + function toEntry( + baseUrl: string, + anime: { url?: string; title?: string; thumbnail_url?: string }, + source: ExtensionSource, + ) { + return { + url: anime.url ?? '', + title: anime.title ?? 'Untitled', + thumbnailUrl: anime.thumbnail_url + ? resolveBridgeMediaUrl(baseUrl, anime.thumbnail_url) + : null, + sourceId: source.id, + sourceName: source.name, + } satisfies AnimeBrowserEntry; + } + + /** The one source a per-anime call must run against. */ + function requireSource(sourceId: string | null): ExtensionSource { + const source = sources.find((candidate) => candidate.id === sourceId); + if (!source) { + throw new Error( + sourceId === ALL_SOURCES_ID ? 'Pick a single source for this.' : 'Select a source first.', + ); + } + return source; + } + + /** + * Run a listing call against the selected source, or against every installed + * source when "all sources" is selected. + * + * With one source a failure rejects, as it always has. Across all of them a + * failure is reported alongside the sources that did answer, so one broken + * extension cannot blank the grid. + */ + async function browse( + page: number, + fetchPage: ( + source: Awaited>, + page: number, + ) => Promise, + ): Promise { + const { baseUrl } = requireBridge(); + const targets = + selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)]; + if (targets.length === 0) throw new Error('No sources are installed.'); + + // Each source's answer is pushed the moment it lands, so a fast source is + // on screen while a slow one is still resolving. Guarded by the token: a + // superseded search stops emitting, and its remaining sources run out + // quietly. + const token = ++searchToken; + const emit = (update: AnimeBrowserSearchUpdate): void => { + if (token === searchToken) deps.onSearchUpdate?.(update); + }; + emit({ kind: 'start', token, sourceCount: targets.length }); + + const { results, failures } = await mapSourcesConcurrently(targets, async (source) => { + try { + const response = await fetchPage(await sourceFor(source.id), page); + const entries = (response.animes ?? []).map((anime) => toEntry(baseUrl, anime, source)); + emit({ kind: 'result', token, sourceId: source.id, sourceName: source.name, entries }); + return { entries, hasNextPage: response.hasNextPage === true }; + } catch (error) { + emit({ + kind: 'failure', + token, + failure: { sourceId: source.id, sourceName: source.name, error: describeError(error) }, + }); + throw error; + } + }); + + emit({ kind: 'done', token }); + + // A single-source browse has no other source to fall back on, so surface + // the error the way a direct call would. + if (targets.length === 1 && failures[0]) throw new Error(failures[0].error); + + return { + entries: interleave(results.map((result) => result.entries)), + hasNextPage: results.some((result) => result.hasNextPage), + failures, + }; + } + + return { + getSnapshot(): AnimeBrowserSnapshot { + return { + bridge: bridgeState, + sources: sources.map((source) => ({ + id: source.id, + name: source.name, + lang: source.lang, + pkg: source.pkg, + })), + selectedSourceId, + loadFailures, + installed: toInstalledExtensionViews(extensions, sources, loadFailures), + extensionsDir: deps.extensionsDir(), + repos: deps.repos(), + }; + }, + + ensureBridge, + + /** + * Extensions available from the configured repositories, annotated with + * what is installed. Returns nothing when no repository is configured — + * SubMiner never supplies one. + */ + async listAvailableExtensions(): Promise { + const repos = deps.repos(); + if (repos.length === 0) return { extensions: [], failures: [] }; + + const catalogue = await fetchRepoCatalogue(repos); + const installedPkgs = new Set(extensions.map((extension) => extension.fallbackName)); + return { + extensions: catalogue.extensions.map((extension) => ({ + pkg: extension.pkg, + name: extension.name, + lang: extension.lang, + version: extension.version, + nsfw: extension.nsfw, + repoUrl: extension.repoUrl, + sourceNames: extension.sourceNames, + installed: installedPkgs.has(extension.pkg), + })), + failures: catalogue.failures, + }; + }, + + /** Download an extension by package name, then rescan. */ + async installExtension(pkg: string): Promise { + const repos = deps.repos(); + if (repos.length === 0) throw new Error('No extension repository is configured.'); + + const catalogue = await fetchRepoCatalogue(repos); + const match = catalogue.extensions.find((candidate) => candidate.pkg === pkg); + if (!match) throw new Error(`${pkg} is not offered by any configured repository.`); + + await installExtensionFrom(match); + }, + + /** Remove an installed extension, then rescan. */ + async removeExtension(pkg: string): Promise { + await removeExtensionFile(deps.extensionsDir(), pkg); + await preferenceStore.clear(pkg).catch(() => undefined); + if (sidecar) await scanExtensions(sidecar); + }, + + /** + * Add a repository index URL. Rejected unless it is an https index URL, so + * a typo surfaces immediately instead of failing later at fetch time. + */ + addRepo(url: string): void { + const trimmed = url.trim(); + if (!isValidRepoUrl(trimmed)) { + throw new Error('A repository URL must be https and point at a .json index file.'); + } + const repos = deps.repos(); + if (repos.includes(trimmed)) return; + deps.setRepos([...repos, trimmed]); + }, + + removeRepo(url: string): void { + deps.setRepos(deps.repos().filter((candidate) => candidate !== url)); + }, + + /** Re-read the extensions directory without restarting the bridge. */ + async rescanExtensions(): Promise { + if (sidecar) await scanExtensions(sidecar); + }, + + selectSource(sourceId: string): void { + if (sourceId === ALL_SOURCES_ID && sources.length > 0) { + selectedSourceId = ALL_SOURCES_ID; + return; + } + if (sources.some((source) => source.id === sourceId)) selectedSourceId = sourceId; + }, + + /** + * The extension's settings schema, merged with anything saved locally. The + * extension is the source of truth for structure; saved values only supply + * what it has no memory of between requests. + */ + async getPreferences(sourceId: string): Promise { + const { client } = requireBridge(); + const source = requireSource(sourceId); + const schema = await client.getSourcePreferences(await sourceFor(source.id)); + return parsePreferences(schema); + }, + + /** + * Persist one preference and hand the whole array back to the extension so + * it can react (Jellyfin logs in and populates its library list here). + */ + async setPreference( + sourceId: string, + key: string, + value: string | string[] | boolean, + ): Promise { + const { client } = requireBridge(); + const source = await sourceFor(sourceId); + + // Start from the extension's own schema so saved values never go stale + // against an updated extension. + const current = + source.preferences && source.preferences.length > 0 + ? source.preferences + : await client.getSourcePreferences(source); + + const updated = applyPreferenceValue(current, key, value); + await preferenceStore.set(sourceId, updated); + + const refreshed = await client.setSourcePreference({ ...source, preferences: updated }, key); + if (refreshed.length > 0) await preferenceStore.set(sourceId, refreshed); + return parsePreferences(refreshed.length > 0 ? refreshed : updated); + }, + + async search(query: string, page = 1): Promise { + const { client } = requireBridge(); + return browse(page, (source, requestedPage) => + client.searchAnime(source, query, requestedPage), + ); + }, + + async getPopular(page = 1): Promise { + const { client } = requireBridge(); + return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage)); + }, + + async getDetails(animeUrl: string, sourceId?: string): Promise { + const { client, baseUrl } = requireBridge(); + const source = requireSource(sourceId ?? selectedSourceId); + const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl); + return { + ...toEntry(baseUrl, { ...details, url: details.url ?? animeUrl }, source), + description: details.description ?? null, + author: details.author ?? null, + genres: details.genres ?? [], + status: parseAnimeStatus(details.status), + }; + }, + + async getEpisodes(animeUrl: string, sourceId?: string): Promise { + const { client } = requireBridge(); + const source = requireSource(sourceId ?? selectedSourceId); + const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl); + return episodes.map((episode) => ({ + url: episode.url ?? '', + name: episode.name ?? 'Episode', + number: typeof episode.episode_number === 'number' ? episode.episode_number : null, + uploadedAt: + typeof episode.date_upload === 'number' && episode.date_upload > 0 + ? episode.date_upload + : null, + scanlator: episode.scanlator ?? null, + })); + }, + + async playEpisode(request: AnimeBrowserPlayRequest): Promise { + try { + const { client, baseUrl } = requireBridge(); + const videos = await client.getVideoList( + await sourceFor(request.sourceId), + request.episodeUrl, + ); + const streams = videos + .map((video) => resolveStream(video)) + .filter((stream): stream is NonNullable => stream !== null) + // External tracks come off the same loopback proxy as the video, so + // they need the same rebase onto the port the bridge really uses. + .map((stream) => ({ + ...stream, + url: resolveBridgeMediaUrl(baseUrl, stream.url), + audios: stream.audios.map((track) => ({ + ...track, + url: resolveBridgeMediaUrl(baseUrl, track.url), + })), + subtitles: stream.subtitles.map((track) => ({ + ...track, + url: resolveBridgeMediaUrl(baseUrl, track.url), + })), + })); + + const stream = selectPreferredStream(streams, deps.preferredQuality?.()); + if (!stream) { + return { ok: false, error: 'That source returned no playable video.', quality: null }; + } + + if (!(await deps.ensureMpvConnected())) { + return { + ok: false, + error: 'mpv is not running and could not be started.', + quality: null, + }; + } + + const title = `${request.animeTitle} — ${request.episodeName}`; + for (const command of buildPlaybackCommands({ stream, title })) { + deps.sendMpvCommand(command); + } + + const trackCommands = buildTrackCommands(stream); + if (trackCommands.length > 0) { + deps.log( + `[anime-browser] ${stream.audios.length} external audio, ` + + `${stream.subtitles.length} external subtitle track(s)`, + ); + // mpv attaches added tracks to the file that is loading, so give the + // loadfile a moment to take effect first. Same pause the Jellyfin + // subtitle preload uses. + await wait(TRACK_ATTACH_DELAY_MS); + for (const command of trackCommands) { + deps.sendMpvCommand(command); + } + } + + deps.showVisibleOverlay?.(); + deps.showMpvOsd?.(title); + return { ok: true, error: null, quality: stream.quality || null }; + } catch (error) { + deps.log(`[anime-browser] playback failed: ${String(error)}`); + return { ok: false, error: describeError(error), quality: null }; + } + }, + + async dispose(): Promise { + const handle = sidecar; + sidecar = null; + starting = null; + setState(IDLE_STATE); + await handle?.stop(); + }, + }; +} + +export type AnimeBrowserRuntime = ReturnType; + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index f1b34c952..ad9bd94f4 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -75,6 +75,7 @@ test('build cli command context deps maps handlers and values', () => { openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('config-settings'), openSyncUiWindow: () => {}, + openAnimeBrowserWindow: () => {}, cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), printHelp: () => calls.push('help'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 0295bce7e..c5b5af2d3 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -49,6 +49,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; + openAnimeBrowserWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -109,6 +110,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, + openAnimeBrowserWindow: deps.openAnimeBrowserWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, printHelp: deps.printHelp, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index ea79a2237..4ba3aa887 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -77,6 +77,7 @@ test('cli command context factory composes main deps and context handlers', () = openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, + openAnimeBrowserWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index ab7c0b66f..9f115f9ea 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -106,6 +106,7 @@ test('cli command context main deps builder maps state and callbacks', async () openYomitanSettings: () => calls.push('open-yomitan'), openConfigSettingsWindow: () => calls.push('open-config-settings'), openSyncUiWindow: () => {}, + openAnimeBrowserWindow: () => {}, cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), printHelp: () => calls.push('help'), diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 8918873a0..5054397ee 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -66,6 +66,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; + openAnimeBrowserWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -146,6 +147,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { openYomitanSettings: () => deps.openYomitanSettings(), openConfigSettingsWindow: () => deps.openConfigSettingsWindow(), openSyncUiWindow: () => deps.openSyncUiWindow(), + openAnimeBrowserWindow: () => deps.openAnimeBrowserWindow(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), printHelp: () => deps.printHelp(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index c81f9934c..af739f46c 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -59,6 +59,7 @@ function createDeps() { openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, + openAnimeBrowserWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index b625849f5..3c4c517e0 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -54,6 +54,7 @@ export type CliCommandContextFactoryDeps = { openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; + openAnimeBrowserWindow: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; printHelp: () => void; @@ -136,6 +137,7 @@ export function createCliCommandContext( openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, + openAnimeBrowserWindow: deps.openAnimeBrowserWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, printHelp: deps.printHelp, diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts index 756ed9d4f..fcbcf04ec 100644 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -53,6 +53,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => { openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, + openAnimeBrowserWindow: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, printHelp: () => {}, diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.ts b/src/main/runtime/composers/jellyfin-runtime-composer.ts index 7433ec3f9..470c6cb52 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.ts @@ -130,6 +130,9 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{ export type JellyfinRuntimeComposerResult = ComposerOutputs<{ getResolvedJellyfinConfig: ReturnType; getJellyfinClientInfo: ReturnType; + ensureMpvConnectedForPlayback: ReturnType< + typeof createEnsureMpvConnectedForJellyfinPlaybackHandler + >; reportJellyfinRemoteProgress: ReturnType< typeof composeJellyfinRemoteHandlers >['reportJellyfinRemoteProgress']; @@ -297,6 +300,9 @@ export function composeJellyfinRuntimeHandlers( return { getResolvedJellyfinConfig, getJellyfinClientInfo, + // Shared so other playback sources (the anime browser) reuse the same + // auto-launch in-flight guard instead of racing a second mpv launch. + ensureMpvConnectedForPlayback: ensureMpvConnectedForJellyfinPlayback, reportJellyfinRemoteProgress, reportJellyfinRemoteStopped, handleJellyfinRemotePlay, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index ec7ce28d2..e27bae34f 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -32,6 +32,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { yomitan: false, settings: false, syncWindow: false, + animeBrowser: false, setup: false, show: false, hide: false, diff --git a/src/main/runtime/setup-window-factory.ts b/src/main/runtime/setup-window-factory.ts index c656ae671..c1480efe3 100644 --- a/src/main/runtime/setup-window-factory.ts +++ b/src/main/runtime/setup-window-factory.ts @@ -99,3 +99,19 @@ export function createCreateSyncUiWindowHandler(deps: { backgroundColor: '#24273a', }); } + +export function createCreateAnimeBrowserWindowHandler(deps: { + createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; + preloadPath: string; +}) { + return createSetupWindowHandler(deps, { + // Wider than the other surfaces: this one is a cover-art grid. + width: 1180, + height: 820, + title: 'SubMiner Anime', + show: false, + resizable: true, + preloadPath: deps.preloadPath, + backgroundColor: '#24273a', + }); +} diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 5544e6a9f..dee3bea19 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -74,6 +74,7 @@ test('build tray template handler wires actions and init guards', () => { openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), openSyncUiWindow: () => calls.push('sync-ui'), + openAnimeBrowserWindow: () => calls.push('anime-browser'), exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, @@ -130,6 +131,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => { openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), openSyncUiWindow: () => calls.push('configuration'), + openAnimeBrowserWindow: () => calls.push('anime-browser'), exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => false, diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 76692f695..16533d664 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -49,6 +49,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; + openAnimeBrowser: () => void; exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; @@ -70,6 +71,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; + openAnimeBrowserWindow: () => void; exportLogs: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; @@ -113,6 +115,9 @@ export function createBuildTrayMenuTemplateHandler(deps: { openConfigSettings: () => { deps.openConfigSettingsWindow(); }, + openAnimeBrowser: () => { + deps.openAnimeBrowserWindow(); + }, openSyncUi: () => { deps.openSyncUiWindow(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index aa514f522..222b10c2d 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -34,6 +34,7 @@ test('tray main deps builders return mapped handlers', () => { openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), openSyncUiWindow: () => calls.push('sync-ui'), + openAnimeBrowserWindow: () => {}, exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, @@ -61,6 +62,7 @@ test('tray main deps builders return mapped handlers', () => { openYomitanSettings: () => calls.push('open-yomitan'), openConfigSettings: () => calls.push('open-configuration'), openSyncUi: () => {}, + openAnimeBrowser: () => {}, exportLogs: () => calls.push('open-export-logs'), openJellyfinSetup: () => calls.push('open-jellyfin'), showJellyfinDiscovery: true, diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index fa3e173e1..b79a24f20 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; + openAnimeBrowser: () => void; exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; @@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; + openAnimeBrowserWindow: () => void; exportLogs: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; @@ -85,6 +87,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, + openAnimeBrowserWindow: deps.openAnimeBrowserWindow, exportLogs: deps.exportLogs, openJellyfinSetupWindow: deps.openJellyfinSetupWindow, isJellyfinConfigured: deps.isJellyfinConfigured, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index 18918db99..7192a0600 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -34,6 +34,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, + openAnimeBrowserWindow: () => {}, exportLogs: () => {}, openJellyfinSetupWindow: () => {}, isJellyfinConfigured: () => false, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 7c2ab149c..3b776315f 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -40,6 +40,7 @@ test('tray menu template contains expected entries and handlers', () => { openYomitanSettings: () => calls.push('yomitan'), openConfigSettings: () => calls.push('configuration'), openSyncUi: () => calls.push('sync-ui'), + openAnimeBrowser: () => calls.push('anime-browser'), exportLogs: () => calls.push('export-logs'), openJellyfinSetup: () => calls.push('jellyfin'), showJellyfinDiscovery: true, @@ -69,6 +70,7 @@ test('tray menu template contains expected entries and handlers', () => { 'Open Yomitan Settings', 'Open SubMiner Settings', 'Sync Stats && History', + 'Browse Anime', 'Export Logs', 'Configure Jellyfin', 'Jellyfin Discovery', @@ -88,6 +90,7 @@ test('tray menu template contains expected entries and handlers', () => { entryFor('View Changelog').click?.(); entryFor('Open Texthooker').click?.(); entryFor('Sync Stats && History').click?.(); + entryFor('Browse Anime').click?.(); entryFor('Export Logs').click?.(); entryFor('Check for Updates').click?.(); calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad'); @@ -99,6 +102,7 @@ test('tray menu template contains expected entries and handlers', () => { 'changelog', 'texthooker', 'sync-ui', + 'anime-browser', 'export-logs', 'updates', 'separator', @@ -119,6 +123,7 @@ test('tray menu template omits first-run setup entry when setup is complete', () openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, + openAnimeBrowser: () => undefined, exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: false, @@ -149,6 +154,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, + openAnimeBrowser: () => undefined, exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: false, @@ -177,6 +183,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => { openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, + openAnimeBrowser: () => undefined, exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: true, @@ -206,6 +213,7 @@ test('tray menu template renders a visible linux discovery check mark when activ openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, + openAnimeBrowser: () => undefined, exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: true, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 60598f37a..294305997 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -42,6 +42,7 @@ export type TrayMenuActionHandlers = { openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; + openAnimeBrowser: () => void; exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; @@ -113,6 +114,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): label: 'Sync Stats && History', click: handlers.openSyncUi, }, + { + label: 'Browse Anime', + click: handlers.openAnimeBrowser, + }, { label: 'Export Logs', click: handlers.exportLogs, diff --git a/src/main/state.ts b/src/main/state.ts index 728a1603c..50ef2cd5f 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -153,6 +153,7 @@ export interface AppState { firstRunSetupWindow: BrowserWindow | null; configSettingsWindow: BrowserWindow | null; syncUiWindow: BrowserWindow | null; + animeBrowserWindow: BrowserWindow | null; yomitanParserReadyPromise: Promise | null; yomitanParserInitPromise: Promise | null; mpvClient: MpvIpcClient | null; @@ -240,6 +241,7 @@ export function createAppState(values: AppStateInitialValues): AppState { firstRunSetupWindow: null, configSettingsWindow: null, syncUiWindow: null, + animeBrowserWindow: null, yomitanParserReadyPromise: null, yomitanParserInitPromise: null, mpvClient: null, diff --git a/src/preload-animeui.ts b/src/preload-animeui.ts new file mode 100644 index 000000000..1d337c500 --- /dev/null +++ b/src/preload-animeui.ts @@ -0,0 +1,66 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from './shared/ipc/contracts'; +import type { + AnimeBrowserAPI, + AnimeBrowserBridgeState, + AnimeBrowserDetails, + AnimeBrowserEpisode, + AnimeBrowserPlayRequest, + AnimeBrowserPlayResult, + AnimeBrowserSearchResult, + AnimeBrowserSearchUpdate, + AnimeBrowserSnapshot, + AvailableExtensionsResult, + SourcePreferenceView, +} from './types/anime-browser'; + +const request = IPC_CHANNELS.request; + +const animeBrowserAPI: AnimeBrowserAPI = { + getSnapshot: (): Promise => + ipcRenderer.invoke(request.animeBrowserGetSnapshot), + ensureBridge: (): Promise => + ipcRenderer.invoke(request.animeBrowserEnsureBridge), + selectSource: (sourceId: string): Promise => + ipcRenderer.invoke(request.animeBrowserSelectSource, sourceId), + search: (query: string, page?: number): Promise => + ipcRenderer.invoke(request.animeBrowserSearch, query, page), + getPopular: (page?: number): Promise => + ipcRenderer.invoke(request.animeBrowserGetPopular, page), + getDetails: (animeUrl: string, sourceId?: string): Promise => + ipcRenderer.invoke(request.animeBrowserGetDetails, animeUrl, sourceId), + getEpisodes: (animeUrl: string, sourceId?: string): Promise => + ipcRenderer.invoke(request.animeBrowserGetEpisodes, animeUrl, sourceId), + playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise => + ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest), + getPreferences: (sourceId: string): Promise => + ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId), + setPreference: ( + sourceId: string, + key: string, + value: string | string[] | boolean, + ): Promise => + ipcRenderer.invoke(request.animeBrowserSetPreference, sourceId, key, value), + listAvailableExtensions: (): Promise => + ipcRenderer.invoke(request.animeBrowserListAvailableExtensions), + installExtension: (pkg: string): Promise => + ipcRenderer.invoke(request.animeBrowserInstallExtension, pkg), + removeExtension: (pkg: string): Promise => + ipcRenderer.invoke(request.animeBrowserRemoveExtension, pkg), + rescanExtensions: (): Promise => ipcRenderer.invoke(request.animeBrowserRescanExtensions), + addRepo: (url: string): Promise => ipcRenderer.invoke(request.animeBrowserAddRepo, url), + removeRepo: (url: string): Promise => + ipcRenderer.invoke(request.animeBrowserRemoveRepo, url), + onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void): (() => void) => { + const handler = (_event: unknown, state: AnimeBrowserBridgeState): void => listener(state); + ipcRenderer.on(IPC_CHANNELS.event.animeBrowserBridgeState, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserBridgeState, handler); + }, + onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void): (() => void) => { + const handler = (_event: unknown, update: AnimeBrowserSearchUpdate): void => listener(update); + ipcRenderer.on(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler); + }, +}; + +contextBridge.exposeInMainWorld('animeBrowserAPI', animeBrowserAPI); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 50c3a9cca..3270fc5c1 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -125,6 +125,22 @@ export const IPC_CHANNELS = { syncUiRevealSnapshot: 'sync-ui:reveal-snapshot', syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file', getChangelogSnapshot: 'changelog:get-snapshot', + animeBrowserGetSnapshot: 'anime-browser:get-snapshot', + animeBrowserEnsureBridge: 'anime-browser:ensure-bridge', + animeBrowserSelectSource: 'anime-browser:select-source', + animeBrowserSearch: 'anime-browser:search', + animeBrowserGetPopular: 'anime-browser:get-popular', + animeBrowserGetDetails: 'anime-browser:get-details', + animeBrowserGetEpisodes: 'anime-browser:get-episodes', + animeBrowserPlayEpisode: 'anime-browser:play-episode', + animeBrowserGetPreferences: 'anime-browser:get-preferences', + animeBrowserSetPreference: 'anime-browser:set-preference', + animeBrowserListAvailableExtensions: 'anime-browser:list-available-extensions', + animeBrowserInstallExtension: 'anime-browser:install-extension', + animeBrowserRemoveExtension: 'anime-browser:remove-extension', + animeBrowserRescanExtensions: 'anime-browser:rescan-extensions', + animeBrowserAddRepo: 'anime-browser:add-repo', + animeBrowserRemoveRepo: 'anime-browser:remove-repo', }, event: { subtitleSet: 'subtitle:set', @@ -159,6 +175,8 @@ export const IPC_CHANNELS = { notificationHistoryToggle: 'notification-history:toggle', syncUiProgress: 'sync-ui:progress', syncUiStateChanged: 'sync-ui:state-changed', + animeBrowserBridgeState: 'anime-browser:bridge-state', + animeBrowserSearchUpdate: 'anime-browser:search-update', }, } as const; diff --git a/src/types/anime-browser.ts b/src/types/anime-browser.ts new file mode 100644 index 000000000..ee7d33c77 --- /dev/null +++ b/src/types/anime-browser.ts @@ -0,0 +1,204 @@ +import type { AnimeStatus } from '../anime-bridge/media-url'; +import type { SourcePreferenceView } from '../anime-bridge/preferences'; + +/** + * Stands in for a source id when every installed source should answer. Not a + * real source: `getPreferences` and the per-anime calls still need a single one. + */ +export const ALL_SOURCES_ID = '__all__'; + +/** An installed anime extension the browser can search. */ +export interface AnimeBrowserSource { + id: string; + name: string; + lang: string; + /** Package name, e.g. `eu.kanade.tachiyomi.animeextension.all.jellyfin`. */ + pkg: string; +} + +export interface AnimeBrowserEntry { + /** Source-relative url; the handle for every later call. */ + url: string; + title: string; + thumbnailUrl: string | null; + /** + * Which source produced this entry. Carried on the entry rather than read + * from the current selection, so an all-sources result stays usable. + */ + sourceId: string; + sourceName: string; +} + +export interface AnimeBrowserDetails extends AnimeBrowserEntry { + description: string | null; + author: string | null; + genres: string[]; + status: AnimeStatus; +} + +export interface AnimeBrowserEpisode { + url: string; + name: string; + /** Extension-reported episode number; may be fractional for specials. */ + number: number | null; + /** Epoch milliseconds, or null when the source reports no date. */ + uploadedAt: number | null; + scanlator: string | null; +} + +/** One source that errored while the others answered. */ +export interface SourceSearchFailure { + sourceId: string; + sourceName: string; + error: string; +} + +export interface AnimeBrowserSearchResult { + entries: AnimeBrowserEntry[]; + hasNextPage: boolean; + /** + * Sources that failed during an all-sources search. A single-source search + * rejects instead, so this is empty there. + */ + failures: SourceSearchFailure[]; +} + +/** + * Incremental progress of one search, pushed while the search invoke is still + * pending so a fast source is visible before a slow one answers. + * + * `token` orders searches: the renderer keeps the highest `start` token it has + * seen and drops events from any other search, so a stale search that is still + * resolving cannot paint over the one the user just typed. + */ +export type AnimeBrowserSearchUpdate = + | { kind: 'start'; token: number; sourceCount: number } + | { + kind: 'result'; + token: number; + sourceId: string; + sourceName: string; + entries: AnimeBrowserEntry[]; + } + | { kind: 'failure'; token: number; failure: SourceSearchFailure } + | { kind: 'done'; token: number }; + +/** Progress of the one long-running operation: bringing the bridge up. */ +export type AnimeBrowserBridgeStage = + | 'idle' + | 'locating' + | 'downloading' + | 'verifying' + | 'extracting' + | 'starting' + | 'ready' + | 'failed'; + +export interface AnimeBrowserBridgeState { + stage: AnimeBrowserBridgeStage; + /** 0-1 while downloading, otherwise null. */ + progress: number | null; + message: string | null; +} + +/** An extension APK that failed to load, surfaced instead of silently vanishing. */ +export interface ExtensionLoadFailure { + /** Package (file) name of the APK that failed. */ + pkg: string; + error: string; +} + +/** An extension offered by a configured repository. */ +export interface AvailableExtension { + pkg: string; + name: string; + lang: string; + version: string; + nsfw: boolean; + repoUrl: string; + sourceNames: string[]; + installed: boolean; +} + +export interface RepoFailure { + repoUrl: string; + error: string; +} + +export interface AvailableExtensionsResult { + extensions: AvailableExtension[]; + failures: RepoFailure[]; +} + +/** + * An extension present in the extensions directory. Listed from disk rather + * than from a repository, so an APK dropped in by hand — or one whose + * repository has since been removed — can still be seen and removed. + */ +export interface InstalledExtensionView { + pkg: string; + /** The sources it provides, or the file name when it provided none. */ + name: string; + /** Languages its sources cover, deduplicated. */ + langs: string[]; + /** How many sources it provides; 0 when it failed to load. */ + sourceCount: number; + /** Why it failed to load, or null when it loaded. */ + error: string | null; +} + +export interface AnimeBrowserSnapshot { + bridge: AnimeBrowserBridgeState; + sources: AnimeBrowserSource[]; + selectedSourceId: string | null; + loadFailures: ExtensionLoadFailure[]; + /** Every extension on disk, whether or not it loaded. */ + installed: InstalledExtensionView[]; + /** Where APKs are read from, shown so the user knows where to drop files. */ + extensionsDir: string; + /** Configured repository index URLs. Empty until the user adds one. */ + repos: string[]; +} + +export interface AnimeBrowserPlayRequest { + sourceId: string; + animeUrl: string; + animeTitle: string; + episodeUrl: string; + episodeName: string; +} + +export interface AnimeBrowserPlayResult { + ok: boolean; + /** Populated when ok is false, phrased for display. */ + error: string | null; + quality: string | null; +} + +export interface AnimeBrowserAPI { + getSnapshot: () => Promise; + ensureBridge: () => Promise; + selectSource: (sourceId: string) => Promise; + search: (query: string, page?: number) => Promise; + getPopular: (page?: number) => Promise; + /** `sourceId` is required after an all-sources search; pass the entry's own. */ + getDetails: (animeUrl: string, sourceId?: string) => Promise; + getEpisodes: (animeUrl: string, sourceId?: string) => Promise; + playEpisode: (request: AnimeBrowserPlayRequest) => Promise; + getPreferences: (sourceId: string) => Promise; + setPreference: ( + sourceId: string, + key: string, + value: string | string[] | boolean, + ) => Promise; + listAvailableExtensions: () => Promise; + installExtension: (pkg: string) => Promise; + removeExtension: (pkg: string) => Promise; + rescanExtensions: () => Promise; + addRepo: (url: string) => Promise; + removeRepo: (url: string) => Promise; + onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void) => () => void; + onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void) => () => void; +} + +export type { SourcePreferenceView } from '../anime-bridge/preferences'; diff --git a/src/types/config.ts b/src/types/config.ts index 406f63e62..48a1351e4 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -12,6 +12,7 @@ import type { ImmersionTrackingRetentionPreset, TsukihimeConfig, JellyfinConfig, + AnimeConfig, JimakuConfig, JimakuLanguagePreference, StatsConfig, @@ -153,6 +154,7 @@ export interface Config { subtitleStyle?: SubtitleStyleConfig; subtitleSidebar?: SubtitleSidebarConfig; auto_start_overlay?: boolean; + anime?: AnimeConfig; jimaku?: JimakuConfig; /** @deprecated Use tsukihime. */ animetosho?: TsukihimeConfig; @@ -313,6 +315,11 @@ export interface ResolvedConfig { }; subtitleSidebar: ResolvedSubtitleSidebarConfig; auto_start_overlay: boolean; + anime: AnimeConfig & { + extensionsDir: string; + repos: string[]; + preferredQuality: string; + }; jimaku: JimakuConfig & { apiBaseUrl: string; languagePreference: JimakuLanguagePreference; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index a67c88330..f378d2af2 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -42,6 +42,24 @@ export interface YoutubePickerResolveResult { message: string; } +export interface AnimeConfig { + /** + * Directory holding Aniyomi extension `.apk` files. Defaults to + * `/anime-extensions` when unset. + */ + extensionsDir?: string; + /** + * Extension repository index URLs (any https `.json` index, such as + * `https://.../index.min.json`). + * + * Ships empty and stays empty unless the user adds one. SubMiner performs no + * repository discovery and bundles no sources. + */ + repos?: string[]; + /** Preferred stream label, matched as a substring, e.g. "1080". */ + preferredQuality?: string; +} + export interface JimakuConfig { apiKey?: string; apiKeyCommand?: string; From a612d44535e1878f23a3b8d71d1c5192d9c79497 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 31 Jul 2026 17:57:01 -0700 Subject: [PATCH 02/50] fix(anime): harden the extension bridge against untrusted repos and hangs Addresses CodeRabbit review feedback on the Anime Browser: - reject repository package/apk names that are not plain identifiers, and verify the install target resolves inside the extensions directory - count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in header values so a trailing one cannot eat the list separator - key the bridge extension-id cache by APK content, so an in-place upgrade re-uploads instead of running the previous build - bound every bridge, release-listing, and download request with a timeout - enforce the APK size limit while streaming rather than after buffering - read APK bytes on demand instead of holding a base64 copy per extension for the lifetime of the browser - serialize preference mutations and write the file atomically - handle the sidecar spawn error event, and wait for the child to exit in stop() before returning - report a failed Anime Browser bootstrap instead of showing the starting banner forever - keep the preferences panel's save confirmation and in-flight multi-select edits by re-rendering only on a structural schema change --- src/anime-bridge/bridge-client.test.ts | 26 +++++-- src/anime-bridge/bridge-client.ts | 39 ++++++++-- src/anime-bridge/extension-installer.test.ts | 43 +++++++++++ src/anime-bridge/extension-installer.ts | 75 ++++++++++++++++++-- src/anime-bridge/extension-repo.test.ts | 20 ++++++ src/anime-bridge/extension-repo.ts | 13 +++- src/anime-bridge/extension-store.test.ts | 55 ++++++++------ src/anime-bridge/extension-store.ts | 40 +++++++++-- src/anime-bridge/headers.test.ts | 7 ++ src/anime-bridge/headers.ts | 6 +- src/anime-bridge/mpv-playback.test.ts | 11 +++ src/anime-bridge/mpv-playback.ts | 6 +- src/anime-bridge/preference-store.test.ts | 69 ++++++++++++++++++ src/anime-bridge/preference-store.ts | 63 ++++++++++++---- src/anime-bridge/sidecar-process.test.ts | 47 ++++++++++++ src/anime-bridge/sidecar-process.ts | 37 ++++++++-- src/animeui/animeui.ts | 33 +++++---- src/animeui/preferences-fields.ts | 53 +++++++++++++- src/main/runtime/anime-bridge-installer.ts | 9 ++- 19 files changed, 569 insertions(+), 83 deletions(-) create mode 100644 src/anime-bridge/preference-store.test.ts create mode 100644 src/anime-bridge/sidecar-process.test.ts diff --git a/src/anime-bridge/bridge-client.test.ts b/src/anime-bridge/bridge-client.test.ts index 62a75c6fe..eb1706485 100644 --- a/src/anime-bridge/bridge-client.test.ts +++ b/src/anime-bridge/bridge-client.test.ts @@ -4,7 +4,12 @@ import { AnimeBridgeClient, BridgeExtensionError } from './bridge-client'; import { BRIDGE_CONTEXT_KEY } from './types'; const EXTENSION_ID = 'a'.repeat(64); -const source = { apkBase64: 'QVBLLUJZVEVT', sourceId: 'source-1' }; +const APK_BASE64 = 'QVBLLUJZVEVT'; +const source = { + fingerprint: 'sha-1', + loadApkBase64: async () => APK_BASE64, + sourceId: 'source-1', +}; interface Recorded { url: string; @@ -69,7 +74,7 @@ test('getVideoList posts the APK and episode url with a bridge context preferenc 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, source.apkBase64); + 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); }); @@ -81,12 +86,25 @@ test('a cached extension id replaces the APK upload on later calls', async () => await client.getVideoList(source, 'https://origin.example/ep/1'); await client.getVideoList(source, 'https://origin.example/ep/2'); - assert.equal(calls[0]?.body.data, source.apkBase64); + 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); @@ -101,7 +119,7 @@ test('a 409 re-uploads the APK once and succeeds', async () => { assert.equal(calls.length, 3); assert.equal(calls[1]?.body.extensionId, EXTENSION_ID); - assert.equal(calls[2]?.body.data, source.apkBase64); + assert.equal(calls[2]?.body.data, APK_BASE64); assert.equal(videos.length, 1); }); diff --git a/src/anime-bridge/bridge-client.ts b/src/anime-bridge/bridge-client.ts index a45988dc1..2a20a0768 100644 --- a/src/anime-bridge/bridge-client.ts +++ b/src/anime-bridge/bridge-client.ts @@ -13,8 +13,16 @@ const EXTENSION_ID_HEADER = 'x-mangatan-extension-id'; const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/; export interface BridgeSource { - /** Base64-encoded extension APK. */ - apkBase64: string; + /** + * 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; /** Selects one source inside a multi-source (SourceFactory) APK. */ sourceId?: string; preferences?: BridgePreference[]; @@ -24,8 +32,19 @@ 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; @@ -46,15 +65,19 @@ export class BridgeExtensionError extends Error { export class AnimeBridgeClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; + private readonly requestTimeoutMs: number; private readonly extensionIds = new Map(); constructor(options: BridgeClientOptions) { this.baseUrl = options.baseUrl.replace(/\/+$/, ''); this.fetchImpl = options.fetchImpl ?? fetch; + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; } async getCapabilities(): Promise { - const response = await this.fetchImpl(`${this.baseUrl}/capabilities`); + const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, { + signal: AbortSignal.timeout(Math.min(CAPABILITIES_TIMEOUT_MS, this.requestTimeoutMs)), + }); if (!response.ok) { throw new Error(`Anime bridge capabilities check failed (${response.status}).`); } @@ -153,7 +176,10 @@ export class AnimeBridgeClient { extras: Record, changedPreferenceKey?: string, ): Promise { - const cacheKey = source.sourceId ?? source.apkBase64; + // 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); @@ -177,7 +203,7 @@ export class AnimeBridgeClient { return body; } - private post( + private async post( method: string, extras: Record, source: BridgeSource, @@ -188,7 +214,7 @@ export class AnimeBridgeClient { method, ...extras, preferences: this.buildPreferences(source, changedPreferenceKey), - ...(extensionId === undefined ? { data: source.apkBase64 } : { extensionId }), + ...(extensionId === undefined ? { data: await source.loadApkBase64() } : { extensionId }), }; return this.fetchImpl(`${this.baseUrl}/dalvik`, { @@ -198,6 +224,7 @@ export class AnimeBridgeClient { Accept: 'application/json', }, body: JSON.stringify(payload), + signal: AbortSignal.timeout(this.requestTimeoutMs), }); } } diff --git a/src/anime-bridge/extension-installer.test.ts b/src/anime-bridge/extension-installer.test.ts index 25f7ae7a7..6f4213867 100644 --- a/src/anime-bridge/extension-installer.test.ts +++ b/src/anime-bridge/extension-installer.test.ts @@ -138,6 +138,49 @@ test('an oversized download is refused even when the length header lies', async 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({ + 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 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`); diff --git a/src/anime-bridge/extension-installer.ts b/src/anime-bridge/extension-installer.ts index 05060036c..55c12b5d9 100644 --- a/src/anime-bridge/extension-installer.ts +++ b/src/anime-bridge/extension-installer.ts @@ -15,11 +15,18 @@ export interface InstallExtensionOptions { 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; } /** 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 { @@ -35,8 +42,9 @@ export function looksLikeApk(bytes: Uint8Array): boolean { export async function installExtension(options: InstallExtensionOptions): Promise { const fetchImpl = options.fetchImpl ?? fetch; 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); + const response = await fetchImpl(options.extension.apkUrl, { signal }); if (!response.ok) { throw new Error(`Downloading ${options.extension.name} failed (${response.status}).`); } @@ -46,21 +54,74 @@ export async function installExtension(options: InstallExtensionOptions): Promis throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`); } - const bytes = new Uint8Array(await response.arrayBuffer()); - if (bytes.byteLength > 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 mkdir(options.extensionsDir, { recursive: true }); - const target = path.join(options.extensionsDir, extensionFileName(options.extension.pkg)); + const target = resolveTarget(options.extensionsDir, options.extension.pkg); await writeFile(target, bytes); 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 { + 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) { + await reader.cancel(); + 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 { - await rm(path.join(extensionsDir, extensionFileName(pkg)), { force: true }); + await rm(resolveTarget(extensionsDir, pkg), { force: true }); } diff --git a/src/anime-bridge/extension-repo.test.ts b/src/anime-bridge/extension-repo.test.ts index a645f76ac..c139b8ce3 100644 --- a/src/anime-bridge/extension-repo.test.ts +++ b/src/anime-bridge/extension-repo.test.ts @@ -87,6 +87,26 @@ test('malformed entries are skipped rather than failing the repo', () => { 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), []); diff --git a/src/anime-bridge/extension-repo.ts b/src/anime-bridge/extension-repo.ts index 5639bfb2f..0f07a0245 100644 --- a/src/anime-bridge/extension-repo.ts +++ b/src/anime-bridge/extension-repo.ts @@ -9,6 +9,16 @@ /** 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 @@ -81,7 +91,8 @@ export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtensio 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) || apk.length === 0) continue; + 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({ diff --git a/src/anime-bridge/extension-store.test.ts b/src/anime-bridge/extension-store.test.ts index 36dd424ca..be2cb3284 100644 --- a/src/anime-bridge/extension-store.test.ts +++ b/src/anime-bridge/extension-store.test.ts @@ -1,6 +1,7 @@ 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 { @@ -22,18 +23,36 @@ async function makeExtensionDir(files: Record): Promise } function fakeClient( - impl: (source: { apkBase64: string }) => Promise, + impl: (source: { fingerprint: string }) => Promise, ): AnimeBridgeClient { return { listAnimeSources: impl } as unknown as AnimeBridgeClient; } -test('readInstalledExtensions reads apks and base64-encodes them', async () => { +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(Buffer.from(extensions[0]!.apkBase64, 'base64').toString(), 'APK-BYTES'); + 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 () => { @@ -50,21 +69,15 @@ test('readInstalledExtensions returns empty for a missing directory', async () = }); test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => { - const extension: InstalledExtension = { - file: '/x/a.apk', - fallbackName: 'a', - apkBase64: 'QQ==', - }; - assert.deepEqual(toBridgeSource(extension), { apkBase64: 'QQ==' }); - assert.deepEqual(toBridgeSource(extension, 'src-1'), { - apkBase64: 'QQ==', - sourceId: 'src-1', - }); + 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', apkBase64: 'QQ==' }, + { file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' }, ]; const client = fakeClient(async () => [ { id: 101, name: 'Source One', lang: 'en' }, @@ -82,7 +95,7 @@ test('listExtensionSources flattens every source a factory apk provides', async test('listExtensionSources falls back to the file name and a default language', async () => { const extensions: InstalledExtension[] = [ - { file: '/x/my-ext.apk', fallbackName: 'my-ext', apkBase64: 'QQ==' }, + { file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a' }, ]; const client = fakeClient(async () => [{ id: '1', name: ' ' }]); @@ -94,14 +107,14 @@ test('listExtensionSources falls back to the file name and a default language', 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', apkBase64: 'QQ==' }, + { 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', apkBase64: 'QQ==' }, + { file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' }, ]; const sources: ExtensionSource[] = [ { id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' }, @@ -115,7 +128,7 @@ test('toInstalledExtensionViews names an extension after the sources it provides test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => { const extensions: InstalledExtension[] = [ - { file: '/x/broken.apk', fallbackName: 'broken', apkBase64: 'QQ==' }, + { file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' }, ]; // A broken APK is still installed, so it must stay listed and removable. @@ -127,12 +140,12 @@ test('toInstalledExtensionViews lists an extension that loaded nothing, with its test('one broken extension does not hide the working ones', async () => { const extensions: InstalledExtension[] = [ - { file: '/x/broken.apk', fallbackName: 'broken', apkBase64: 'QQ==' }, - { file: '/x/good.apk', fallbackName: 'good', apkBase64: 'Qg==' }, + { 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.apkBase64 === 'QQ==') throw new Error('dex2jar failed'); + if (source.fingerprint === 'hash-a') throw new Error('dex2jar failed'); return [{ id: '7', name: 'Good Source', lang: 'en' }]; }); diff --git a/src/anime-bridge/extension-store.ts b/src/anime-bridge/extension-store.ts index e1fcc7f01..4757dccb7 100644 --- a/src/anime-bridge/extension-store.ts +++ b/src/anime-bridge/extension-store.ts @@ -1,4 +1,7 @@ +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'; @@ -15,7 +18,11 @@ export interface InstalledExtension { file: string; /** File name without extension, used when the bridge reports no name. */ fallbackName: string; - apkBase64: 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 { @@ -27,7 +34,14 @@ export interface ExtensionSource { file: string; } -/** Read every .apk in `directory`. A missing directory yields no extensions. */ +/** + * 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 { let entries; try { @@ -40,16 +54,21 @@ export async function readInstalledExtensions(directory: string): Promise a.name.localeCompare(b.name))) { if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue; const file = path.join(directory, entry.name); - const bytes = await readFile(file); extensions.push({ file, fallbackName: entry.name.replace(/\.apk$/i, ''), - apkBase64: bytes.toString('base64'), + sha256: await hashFile(file), }); } return extensions; } +async function hashFile(file: string): Promise { + 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. * @@ -75,9 +94,18 @@ export function toInstalledExtensionViews( }); } -/** The bridge payload for a specific source inside an extension. */ +/** + * 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 { apkBase64: extension.apkBase64, ...(sourceId ? { sourceId } : {}) }; + return { + fingerprint: extension.sha256, + loadApkBase64: async () => (await readFile(extension.file)).toString('base64'), + ...(sourceId ? { sourceId } : {}), + }; } /** diff --git a/src/anime-bridge/headers.test.ts b/src/anime-bridge/headers.test.ts index 8528d3ffe..e6f2d9dc3 100644 --- a/src/anime-bridge/headers.test.ts +++ b/src/anime-bridge/headers.test.ts @@ -28,6 +28,13 @@ test('toMpvHeaderFields joins entries and escapes commas in values', () => { 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({}), ''); }); diff --git a/src/anime-bridge/headers.ts b/src/anime-bridge/headers.ts index 480b98e10..4d19e71d9 100644 --- a/src/anime-bridge/headers.ts +++ b/src/anime-bridge/headers.ts @@ -19,11 +19,13 @@ export function parseOkHttpHeaders(headers: OkHttpHeaders | undefined): Record): string { return Object.entries(headers) - .map(([name, value]) => `${name}: ${value.replace(/,/g, '\\,')}`) + .map(([name, value]) => `${name}: ${value.replace(/\\/g, '\\\\').replace(/,/g, '\\,')}`) .join(','); } diff --git a/src/anime-bridge/mpv-playback.test.ts b/src/anime-bridge/mpv-playback.test.ts index 51cc83521..c6c9fd3ee 100644 --- a/src/anime-bridge/mpv-playback.test.ts +++ b/src/anime-bridge/mpv-playback.test.ts @@ -57,6 +57,17 @@ test('the escape length counts the full header string including separators', () 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')); diff --git a/src/anime-bridge/mpv-playback.ts b/src/anime-bridge/mpv-playback.ts index 3fa05e816..9cdbffb86 100644 --- a/src/anime-bridge/mpv-playback.ts +++ b/src/anime-bridge/mpv-playback.ts @@ -77,9 +77,13 @@ export function buildLoadfileOptions(options: BuildPlaybackOptions): string { * 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 `%${value.length}%${value}`; + return `%${Buffer.byteLength(value, 'utf8')}%${value}`; } /** diff --git a/src/anime-bridge/preference-store.test.ts b/src/anime-bridge/preference-store.test.ts new file mode 100644 index 000000000..ca36b3d8f --- /dev/null +++ b/src/anime-bridge/preference-store.test.ts @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PreferenceStore } from './preference-store'; + +async function storeFile(): Promise { + 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('src-1', [{ key: 'address' }]); + + assert.deepEqual(await new PreferenceStore(file).get('src-1'), [{ key: 'address' }]); +}); + +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('src-1', [{ key: 'a' }]), store.set('src-2', [{ key: 'b' }])]); + + const reloaded = new PreferenceStore(file); + assert.deepEqual(await reloaded.get('src-1'), [{ key: 'a' }]); + assert.deepEqual(await reloaded.get('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 and leaves no temporary behind', async () => { + const file = await storeFile(); + await new PreferenceStore(file).set('src-1', [{ key: 'password' }]); + + const { stat } = await import('node:fs/promises'); + 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('src-1'), []); +}); + +test('a write replaces the previous contents wholesale', async () => { + const file = await storeFile(); + const store = new PreferenceStore(file); + await store.set('src-1', [{ key: 'first' }]); + await store.set('src-1', [{ key: 'second' }]); + + const parsed = JSON.parse(await readFile(file, 'utf8')) as Record; + assert.deepEqual(parsed['src-1'], [{ key: 'second' }]); +}); diff --git a/src/anime-bridge/preference-store.ts b/src/anime-bridge/preference-store.ts index 30ca4d93c..23a416d24 100644 --- a/src/anime-bridge/preference-store.ts +++ b/src/anime-bridge/preference-store.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises'; import path from 'node:path'; import type { BridgePreference } from './types'; @@ -11,11 +11,24 @@ import type { BridgePreference } from './types'; export class PreferenceStore { private readonly file: string; private cache: Record | 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 = Promise.resolve(); constructor(file: string) { this.file = file; } + private enqueue(operation: () => Promise): Promise { + 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> { if (this.cache !== null) return this.cache; try { @@ -32,14 +45,18 @@ export class PreferenceStore { } async get(sourceId: string): Promise { - const all = await this.load(); - return all[sourceId] ?? []; + return this.enqueue(async () => { + const all = await this.load(); + return all[sourceId] ?? []; + }); } async set(sourceId: string, preferences: BridgePreference[]): Promise { - const all = await this.load(); - all[sourceId] = preferences; - await this.persist(all); + await this.enqueue(async () => { + const all = await this.load(); + all[sourceId] = preferences; + await this.persist(all); + }); } /** @@ -50,19 +67,35 @@ export class PreferenceStore { * package name and this clears anything recorded under it. */ async clear(prefix: string): Promise { - 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; + 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); + 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): Promise { await mkdir(path.dirname(this.file), { recursive: true }); - await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 }); + const temporary = `${this.file}.tmp`; + try { + await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 }); + await rename(temporary, this.file); + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } } } diff --git a/src/anime-bridge/sidecar-process.test.ts b/src/anime-bridge/sidecar-process.test.ts new file mode 100644 index 000000000..0d9ac72b5 --- /dev/null +++ b/src/anime-bridge/sidecar-process.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +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. */ +function fakeChild(): ChildProcess { + const child = new EventEmitter(); + Object.assign(child, { stdout: null, stderr: null, kill: () => 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('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/, + ); +}); diff --git a/src/anime-bridge/sidecar-process.ts b/src/anime-bridge/sidecar-process.ts index 5123d8f93..4675b4104 100644 --- a/src/anime-bridge/sidecar-process.ts +++ b/src/anime-bridge/sidecar-process.ts @@ -7,6 +7,12 @@ 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 { + 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 { @@ -68,8 +74,20 @@ export async function startSidecar(options: StartSidecarOptions): Promise { - exited = { code, signal }; + let spawnError: Error | null = null; + const hasExited = new Promise((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. + child.once('error', (error: Error) => { + spawnError = error; + if (exited === null) exited = { code: null, signal: null }; + resolve(); + }); }); const stop = async (): Promise => { @@ -80,13 +98,24 @@ export async function startSidecar(options: StartSidecarOptions): Promise setTimeout(resolve, READY_POLL_INTERVAL_MS)); + await delay(READY_POLL_INTERVAL_MS); } await stop(); diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts index f5a942e03..7ebb077e9 100644 --- a/src/animeui/animeui.ts +++ b/src/animeui/animeui.ts @@ -488,18 +488,25 @@ api.onBridgeState(renderBridgeState); void (async () => { renderBridgeState({ stage: 'idle', progress: null, message: null }); - 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'); + 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'); } })(); diff --git a/src/animeui/preferences-fields.ts b/src/animeui/preferences-fields.ts index 579dba48b..fdf3cd39d 100644 --- a/src/animeui/preferences-fields.ts +++ b/src/animeui/preferences-fields.ts @@ -19,6 +19,29 @@ 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, @@ -46,10 +69,14 @@ function renderPreferenceField( state.removeAttribute('data-tone'); state.textContent = 'Saving…'; try { - const refreshed = await commit(view.key, value); + // 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'; - renderPreferences(container, refreshed, commit); + if (schemaShape(refreshed) !== renderedShapes.get(container)) { + renderPreferences(container, refreshed, commit); + } } catch (error) { state.dataset.tone = 'error'; state.textContent = describe(error); @@ -120,11 +147,33 @@ function renderPreferenceField( return field; } +/** Shape currently on screen, per container, so a save can skip a no-op rebuild. */ +const renderedShapes = new WeakMap(); + +/** Serializes commits per container; see the comment in `save`. */ +const commitQueues = new WeakMap>(); + +function enqueueCommit( + container: HTMLElement, + run: () => Promise, +): Promise { + 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'); diff --git a/src/main/runtime/anime-bridge-installer.ts b/src/main/runtime/anime-bridge-installer.ts index 1703e5752..dcc1fec5a 100644 --- a/src/main/runtime/anime-bridge-installer.ts +++ b/src/main/runtime/anime-bridge-installer.ts @@ -17,6 +17,10 @@ import { export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extracting'; +/** Neither call has a default deadline, so a hung network would stall install. */ +const RELEASES_TIMEOUT_MS = 30_000; +const DOWNLOAD_TIMEOUT_MS = 300_000; + export interface InstallProgress { stage: InstallStage; /** 0-1 during download, otherwise null. */ @@ -119,6 +123,7 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis options.onProgress?.({ stage: 'locating', progress: null }); const releasesResponse = await fetchImpl(BUNDLE_RELEASES_URL, { headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(RELEASES_TIMEOUT_MS), }); if (!releasesResponse.ok) { throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`); @@ -129,7 +134,9 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis } options.onProgress?.({ stage: 'downloading', progress: 0 }); - const downloadResponse = await fetchImpl(asset.downloadUrl); + const downloadResponse = await fetchImpl(asset.downloadUrl, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); if (!downloadResponse.ok) { throw new Error(`Downloading the anime bridge failed (${downloadResponse.status}).`); } From c201520da36e1e33039c199fc0723c2086512f60 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 31 Jul 2026 18:07:32 -0700 Subject: [PATCH 03/50] fix(anime): honor the sidecar readiness deadline and confirm shutdown The capabilities probe used a fixed 5s timeout, so a readiness budget shorter than that could be overrun by one stalled request. Pass the remaining deadline down instead. stop() also resolved after the SIGKILL wait even when the child had not exited, letting a restart race a process still holding the port. Throw in that case, and keep a failed shutdown from masking the readiness error at startup. --- src/anime-bridge/bridge-client.test.ts | 14 ++++++++++++++ src/anime-bridge/bridge-client.ts | 12 ++++++++---- src/anime-bridge/sidecar-process.test.ts | 23 +++++++++++++++++++++-- src/anime-bridge/sidecar-process.ts | 12 ++++++++++-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/anime-bridge/bridge-client.test.ts b/src/anime-bridge/bridge-client.test.ts index eb1706485..9f0da069b 100644 --- a/src/anime-bridge/bridge-client.test.ts +++ b/src/anime-bridge/bridge-client.test.ts @@ -55,6 +55,20 @@ test('isReady requires every capability the client depends on', async () => { 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', diff --git a/src/anime-bridge/bridge-client.ts b/src/anime-bridge/bridge-client.ts index 2a20a0768..c8ba6e202 100644 --- a/src/anime-bridge/bridge-client.ts +++ b/src/anime-bridge/bridge-client.ts @@ -74,9 +74,13 @@ export class AnimeBridgeClient { this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; } - async getCapabilities(): Promise { + /** + * `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 { const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, { - signal: AbortSignal.timeout(Math.min(CAPABILITIES_TIMEOUT_MS, this.requestTimeoutMs)), + signal: AbortSignal.timeout(Math.max(0, Math.min(timeoutMs, this.requestTimeoutMs))), }); if (!response.ok) { throw new Error(`Anime bridge capabilities check failed (${response.status}).`); @@ -85,9 +89,9 @@ export class AnimeBridgeClient { } /** True once the bridge is up and reports the features this client needs. */ - async isReady(): Promise { + async isReady(timeoutMs?: number): Promise { try { - const capabilities = await this.getCapabilities(); + const capabilities = await this.getCapabilities(timeoutMs); return ( capabilities.mangatanMihonBridge === 1 && capabilities.sourceFactory === true && diff --git a/src/anime-bridge/sidecar-process.test.ts b/src/anime-bridge/sidecar-process.test.ts index 0d9ac72b5..d9b292b78 100644 --- a/src/anime-bridge/sidecar-process.test.ts +++ b/src/anime-bridge/sidecar-process.test.ts @@ -11,9 +11,16 @@ const binaries: BundleBinaries = { }; /** A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches. */ -function fakeChild(): ChildProcess { +function fakeChild(onKill?: (child: EventEmitter) => void): ChildProcess { const child = new EventEmitter(); - Object.assign(child, { stdout: null, stderr: null, kill: () => true }); + Object.assign(child, { + stdout: null, + stderr: null, + kill: () => { + onKill?.(child); + return true; + }, + }); return child as unknown as ChildProcess; } @@ -32,6 +39,18 @@ test('a failed spawn rejects instead of throwing an unhandled error event', asyn ); }); +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('an early exit is reported with its code rather than waiting out the deadline', async () => { const port = await allocatePort(); const child = fakeChild(); diff --git a/src/anime-bridge/sidecar-process.ts b/src/anime-bridge/sidecar-process.ts index 4675b4104..3423406a4 100644 --- a/src/anime-bridge/sidecar-process.ts +++ b/src/anime-bridge/sidecar-process.ts @@ -107,6 +107,11 @@ export async function startSidecar(options: StartSidecarOptions): Promise {}); throw new Error( `Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`, ); From 7220f6fc9bcb5213cebc54163ce1bd27902893d9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 31 Jul 2026 23:41:14 -0700 Subject: [PATCH 04/50] feat(anime): add language filter to extensions available list - Add a language chip row above the Available list; pick one or more languages to narrow the repo index, or "All" to clear - Show language display names (e.g. "Japanese") instead of raw tags, and an "N of M" count on the Available heading - Add language-filter.ts with collectLanguages/toggleLanguage/pruneSelection/filterByLanguage/languageLabel, plus tests --- CHANGELOG.md | 4 ++ src/animeui/extensions-panel.ts | 95 +++++++++++++++++++++++------ src/animeui/index.html | 5 +- src/animeui/language-filter.test.ts | 49 +++++++++++++++ src/animeui/language-filter.ts | 80 ++++++++++++++++++++++++ src/animeui/style.css | 40 ++++++++++++ 6 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 src/animeui/language-filter.test.ts create mode 100644 src/animeui/language-filter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42db87fbf..4284da94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Anime Browser Language Filter: The Extensions tab's available list now has a language chip row above it. Pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter — selecting a language replaces "All" rather than sitting beside it. Extension rows name the language ("Japanese" instead of `ja`), and the Available heading shows how many of the offered extensions the filter leaves. + ### Fixed - Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and, on macOS, puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback. diff --git a/src/animeui/extensions-panel.ts b/src/animeui/extensions-panel.ts index afb33bd39..a094434de 100644 --- a/src/animeui/extensions-panel.ts +++ b/src/animeui/extensions-panel.ts @@ -1,5 +1,12 @@ import { describe, el } from './dom'; import { describeInstalled } from './format'; +import { + collectLanguages, + filterByLanguage, + languageLabel, + pruneSelection, + toggleLanguage, +} from './language-filter'; import type { AnimeBrowserAPI, AvailableExtension, @@ -90,10 +97,20 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { const installedList = el('installed-list'); const installedCount = el('installed-count'); const availableList = el('extensions-list'); + const availableCount = el('available-count'); + const langFilter = el('lang-filter'); const repoInput = el('repo-input'); const repoAddButton = el('repo-add'); const repoList = el('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(); + async function afterChange(extensionName: string, verb: string): Promise { await refresh(); await onSourcesChanged(); @@ -179,20 +196,62 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { ); } - function renderAvailable( - available: AvailableExtension[], - repoFailures: Array<{ name: string; error: string }>, - hasRepos: boolean, - ): void { + 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 available) { + for (const extension of shown) { rows.push( extensionRow({ name: extension.name, - sub: `${extension.lang} · v${extension.version}`, + sub: `${languageLabel(extension.lang)} · v${extension.version}`, tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [], actions: [ { @@ -216,9 +275,11 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { if (rows.length === 0) { rows.push( emptyNote( - hasRepos - ? 'Every extension the configured repositories offer is already installed.' - : 'No repository configured, so there is nothing to install from.', + 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.', ), ); } @@ -231,7 +292,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { extensionsDirLabel.textContent = snapshot.extensionsDir; renderRepos(snapshot.repos); - const repoFailures: Array<{ name: string; error: string }> = []; + repoFailures = []; let available; try { available = await api.listAvailableExtensions(); @@ -245,13 +306,11 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg)); renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir); - renderAvailable( - // Installed extensions have their own section; leaving them here too - // would list every one of them twice. - available.extensions.filter((extension) => !extension.installed), - repoFailures, - snapshot.repos.length > 0, - ); + // 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', () => { diff --git a/src/animeui/index.html b/src/animeui/index.html index 235651ab3..e2488e163 100644 --- a/src/animeui/index.html +++ b/src/animeui/index.html @@ -115,7 +115,10 @@

Repositories

-

Available

+

+ Available +

+
diff --git a/src/animeui/language-filter.test.ts b/src/animeui/language-filter.test.ts new file mode 100644 index 000000000..515040eaa --- /dev/null +++ b/src/animeui/language-filter.test.ts @@ -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'], + ); +}); diff --git a/src/animeui/language-filter.ts b/src/animeui/language-filter.ts new file mode 100644 index 000000000..c8d3ae3af --- /dev/null +++ b/src/animeui/language-filter.ts @@ -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, code: string): Set { + 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, + available: ReadonlyArray, +): Set { + 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( + extensions: ReadonlyArray, + selected: ReadonlySet, +): T[] { + if (selected.size === 0) return [...extensions]; + return extensions.filter((extension) => selected.has(extension.lang)); +} diff --git a/src/animeui/style.css b/src/animeui/style.css index ae8f995af..6f59b5535 100644 --- a/src/animeui/style.css +++ b/src/animeui/style.css @@ -823,6 +823,46 @@ body { 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; From f8a8235b7dae6ffdaf9fa3b8dcef593a55b20730 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 31 Jul 2026 23:41:46 -0700 Subject: [PATCH 05/50] fix(anime): pin linux-x64 bridge checksum and fetch release by tag - Add PINNED_BUNDLE_SHA256 entry for linux-x64-bundle.zip so Linux no longer refuses to start with "No pinned checksum" - Fetch the bridge release by its pinned tag (releases/tags/) instead of listing newest releases, so an upstream publish can't swap in an unverified asset - Update docs and changelog to describe the pin/tag behavior --- changes/anime-browser.md | 2 + docs-site/anime-browser.md | 14 ++++-- src/anime-bridge/sidecar-bundle.test.ts | 58 +++++++++++++++++++--- src/anime-bridge/sidecar-bundle.ts | 33 +++++++++--- src/main/runtime/anime-bridge-installer.ts | 11 ++-- 5 files changed, 94 insertions(+), 24 deletions(-) diff --git a/changes/anime-browser.md b/changes/anime-browser.md index b360a9ddd..ca23ac8f1 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -13,3 +13,5 @@ area: anime - 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. +- 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. diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index fbffd5fdb..aa6156549 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -129,9 +129,11 @@ credentials, it is written with owner-only permissions. ## 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. It is verified against a -pinned SHA-256 before running, unpacked into `/anime-bridge`, and -reused after that. Progress appears in the banner at the top of the window. +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 +`/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 @@ -142,8 +144,10 @@ 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). - Other platforms are unsupported. +- 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 diff --git a/src/anime-bridge/sidecar-bundle.test.ts b/src/anime-bridge/sidecar-bundle.test.ts index 265d0ecd7..d745e8ed1 100644 --- a/src/anime-bridge/sidecar-bundle.test.ts +++ b/src/anime-bridge/sidecar-bundle.test.ts @@ -4,6 +4,7 @@ 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, @@ -26,34 +27,71 @@ test('resolveBundleAssetName returns null for unpublished combinations', () => { 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: 'v1.0.6.0', + tag_name: 'v9.9.9.9', assets: [ { name: 'macOS-arm64-bundle.zip', - browser_download_url: 'https://example.test/macOS-arm64-bundle.zip', - size: 133_058_560, + browser_download_url: 'https://example.test/unpinned.zip', + size: 1, }, ], }, + PINNED_RELEASE, ]; const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip'); - assert.equal(asset?.tagName, 'v1.0.6.0'); + 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 returns null when nothing matches', () => { - assert.equal(selectBundleAsset([{ tag_name: 'v1', assets: [] }], 'linux-x64-bundle.zip'), null); + 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 }); @@ -103,15 +141,19 @@ test('verifyPinnedBundle accepts a matching hash and rejects a mismatch', () => }); test('verifyPinnedBundle refuses an asset that has no pin', () => { - const result = verifyPinnedBundle('linux-x64-bundle.zip', new Uint8Array([1, 2, 3])); + 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 macOS arm64 hash are the verified release', () => { +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', + ); }); diff --git a/src/anime-bridge/sidecar-bundle.ts b/src/anime-bridge/sidecar-bundle.ts index 56cbf9005..4be9ea5d8 100644 --- a/src/anime-bridge/sidecar-bundle.ts +++ b/src/anime-bridge/sidecar-bundle.ts @@ -8,8 +8,15 @@ import path from 'node:path'; * required. */ -export const BUNDLE_RELEASES_URL = - 'https://api.github.com/repos/1Selxo/M-Extension-Server/releases?page=1&per_page=10'; +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. @@ -24,6 +31,7 @@ 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> = { 'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6', + 'linux-x64-bundle.zip': 'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69', }; /** @@ -130,12 +138,23 @@ interface GithubRelease { } /** - * Pick the newest release carrying an asset for this platform. Releases are - * returned newest-first, and some (the iOS runtime) carry no desktop bundle. + * 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): BundleAsset | null { - if (!Array.isArray(releases)) return null; - for (const release of releases as GithubRelease[]) { +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 { diff --git a/src/main/runtime/anime-bridge-installer.ts b/src/main/runtime/anime-bridge-installer.ts index dcc1fec5a..0c1dcce8a 100644 --- a/src/main/runtime/anime-bridge-installer.ts +++ b/src/main/runtime/anime-bridge-installer.ts @@ -2,8 +2,9 @@ import { spawn } from 'node:child_process'; import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { - BUNDLE_RELEASES_URL, + bundleReleaseUrl, findBundleBinaries, + PINNED_BUNDLE_TAG, resolveBundleAssetName, selectBundleAsset, verifyPinnedBundle, @@ -121,16 +122,18 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis } options.onProgress?.({ stage: 'locating', progress: null }); - const releasesResponse = await fetchImpl(BUNDLE_RELEASES_URL, { + const releasesResponse = await fetchImpl(bundleReleaseUrl(), { headers: { Accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(RELEASES_TIMEOUT_MS), }); if (!releasesResponse.ok) { - throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`); + throw new Error( + `Could not read anime bridge release ${PINNED_BUNDLE_TAG} (${releasesResponse.status}).`, + ); } const asset = selectBundleAsset(await releasesResponse.json(), assetName); if (asset === null) { - throw new Error(`No published anime bridge release contains ${assetName}.`); + throw new Error(`Anime bridge release ${PINNED_BUNDLE_TAG} has no ${assetName}.`); } options.onProgress?.({ stage: 'downloading', progress: 0 }); From 7643855e617ea9b5b1928214413bd105511b3721 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 00:05:31 -0700 Subject: [PATCH 06/50] fix(anime): strip disguised HLS segments and report real playback errors Some hosts prepend a fake image header (a real 1x1 PNG) to every HLS segment; ffmpeg probes the segment as a picture and mpv drops back to idle with no window while the browser claims the episode is playing. - Route bridge-served m3u8 streams through a local strip proxy that scans each segment for the first genuine MPEG-TS packet run and drops the junk in front of it; playlists get absolute origins rewritten so segment requests come back through the proxy. Non-TS bodies pass through untouched. - Only report ok from playEpisode once mpv configures a video output; an end-file with reason error surfaces mpv's own message (e.g. "no audio or video data played") in the browser status bar instead of "Playing". New end-file event plumbed through the mpv IPC client. --- changes/anime-browser.md | 2 + docs-site/anime-browser.md | 15 ++ src/anime-bridge/media-url.test.ts | 28 ++- src/anime-bridge/media-url.ts | 24 ++ src/anime-bridge/playback-outcome.test.ts | 93 ++++++++ src/anime-bridge/playback-outcome.ts | 80 +++++++ src/anime-bridge/stream-strip-proxy.test.ts | 165 ++++++++++++++ src/anime-bridge/stream-strip-proxy.ts | 232 ++++++++++++++++++++ src/core/services/mpv-protocol.test.ts | 22 ++ src/core/services/mpv-protocol.ts | 9 + src/core/services/mpv.ts | 4 + src/main.ts | 11 + src/main/runtime/anime-browser-runtime.ts | 107 +++++++-- 13 files changed, 768 insertions(+), 24 deletions(-) create mode 100644 src/anime-bridge/playback-outcome.test.ts create mode 100644 src/anime-bridge/playback-outcome.ts create mode 100644 src/anime-bridge/stream-strip-proxy.test.ts create mode 100644 src/anime-bridge/stream-strip-proxy.ts diff --git a/changes/anime-browser.md b/changes/anime-browser.md index ca23ac8f1..b33b12134 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -13,5 +13,7 @@ area: anime - 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. +- "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. diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index aa6156549..cdc2bc963 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -155,6 +155,21 @@ 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 diff --git a/src/anime-bridge/media-url.test.ts b/src/anime-bridge/media-url.test.ts index 48e286adb..a3ede0b8e 100644 --- a/src/anime-bridge/media-url.test.ts +++ b/src/anime-bridge/media-url.test.ts @@ -1,8 +1,34 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { parseAnimeStatus, resolveBridgeMediaUrl } from './media-url'; +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( diff --git a/src/anime-bridge/media-url.ts b/src/anime-bridge/media-url.ts index bf6f18461..3da4708eb 100644 --- a/src/anime-bridge/media-url.ts +++ b/src/anime-bridge/media-url.ts @@ -47,6 +47,30 @@ export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string): 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' diff --git a/src/anime-bridge/playback-outcome.test.ts b/src/anime-bridge/playback-outcome.test.ts new file mode 100644 index 000000000..606409079 --- /dev/null +++ b/src/anime-bridge/playback-outcome.test.ts @@ -0,0 +1,93 @@ +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; +}; + +function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: number }) { + const listeners = new Set<(event: PlaybackEndFileEvent) => void>(); + const properties = new Map(); + const failing = new Set(); + + const watch = watchPlaybackOutcome({ + onEndFile: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + readProperty: async (name) => { + if (failing.has(name)) throw new Error(`Failed to read MPV property '${name}'`); + return properties.get(name); + }, + wait: async () => {}, + 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), + }; + 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 } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 }); + const outcome = await watch.wait(); + assert.equal(outcome.ok, false); + assert.ok(!outcome.ok && outcome.error.length > 0); + 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); +}); diff --git a/src/anime-bridge/playback-outcome.ts b/src/anime-bridge/playback-outcome.ts new file mode 100644 index 000000000..dd4731e52 --- /dev/null +++ b/src/anime-bridge/playback-outcome.ts @@ -0,0 +1,80 @@ +/** + * 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; + wait: (ms: number) => Promise; + timeoutMs?: number; + probeIntervalMs?: number; +} + +export interface PlaybackOutcomeWatch { + wait: () => Promise; + 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 { + for (let elapsed = 0; elapsed < timeoutMs; elapsed += probeIntervalMs) { + 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; + await deps.wait(probeIntervalMs); + } + return ( + failure ?? { + ok: false, + error: 'Playback did not start. mpv gave no error; try another server or quality.', + } + ); + } + + return { wait, dispose: unsubscribe }; +} diff --git a/src/anime-bridge/stream-strip-proxy.test.ts b/src/anime-bridge/stream-strip-proxy.test.ts new file mode 100644 index 000000000..5b16b9a0d --- /dev/null +++ b/src/anime-bridge/stream-strip-proxy.test.ts @@ -0,0 +1,165 @@ +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 }; + +async function withProxy( + routes: Record, + run: (proxyOrigin: string, upstreamOrigin: string) => Promise, +): Promise { + const upstream = http.createServer((req, res) => { + 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((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 }); + try { + await run(proxy.origin, upstreamOrigin); + } finally { + await proxy.close(); + await new Promise((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 () => { + const upstream = http.createServer((req, res) => { + const origin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + if (req.url === '/video/list.m3u8') { + res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' }); + res.end(`#EXTM3U\n#EXTINF:6,\n${origin}/video/abs.ts\n`); + return; + } + res.writeHead(404).end(); + }); + await new Promise((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 }); + try { + const { body } = await fetchBytes(`${proxy.origin}/video/list.m3u8`); + const text = body.toString('utf8'); + assert.ok(text.includes(`${proxy.origin}/video/abs.ts`)); + assert.ok(!text.includes(upstreamOrigin)); + } finally { + await proxy.close(); + await new Promise((resolve) => upstream.close(() => resolve())); + } +}); + +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'); + }, + ); +}); diff --git a/src/anime-bridge/stream-strip-proxy.ts b/src/anime-bridge/stream-strip-proxy.ts new file mode 100644 index 000000000..5a83889d3 --- /dev/null +++ b/src/anime-bridge/stream-strip-proxy.ts @@ -0,0 +1,232 @@ +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; +} + +export interface StreamStripProxyHandle { + origin: string; + port: number; + close: () => Promise; +} + +/** 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 { + const log = options.log ?? (() => {}); + + 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; + + const upstreamRequest = http.request( + upstreamUrl, + { method: req.method, headers: requestHeaders }, + (upstream) => handleUpstreamResponse(req, res, upstream), + ); + upstreamRequest.on('error', (error) => { + 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((resolveClose) => { + server.closeAllConnections?.(); + server.close(() => resolveClose()); + }), + }); + }); + }); +} diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 11991db36..9df20b554 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -373,6 +373,28 @@ test('visibility and boolean parsers handle text values', () => { assert.equal(asBoolean('0', true), false); }); +test('dispatchMpvProtocolMessage emits end-file with reason and file error', async () => { + const received: Array<{ reason: string; fileError: string | null }> = []; + const { deps } = createDeps({ + emitEndFile: (payload) => { + received.push(payload); + }, + }); + + await dispatchMpvProtocolMessage( + { event: 'end-file', reason: 'error', file_error: 'no audio or video data played' }, + deps, + ); + await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'eof' }, deps); + await dispatchMpvProtocolMessage({ event: 'end-file' }, deps); + + assert.deepEqual(received, [ + { reason: 'error', fileError: 'no audio or video data played' }, + { reason: 'eof', fileError: null }, + { reason: 'unknown', fileError: null }, + ]); +}); + test('dispatchMpvProtocolMessage emits client-message string args', async () => { const received: Array<{ args: string[] }> = []; const { deps } = createDeps({ diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index c8c8a22f5..a5117b8ce 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -7,6 +7,8 @@ export type MpvMessage = { args?: unknown; request_id?: number; error?: string; + reason?: unknown; + file_error?: unknown; }; export const MPV_REQUEST_ID_SUBTEXT = 101; @@ -96,6 +98,7 @@ export interface MpvProtocolHandleMessageDeps { shouldQuitOnMpvShutdown: () => boolean; requestAppQuit: () => void; emitClientMessage?: (payload: { args: string[] }) => void; + emitEndFile?: (payload: { reason: string; fileError: string | null }) => void; } type SubtitleTrackCandidate = { @@ -385,6 +388,12 @@ export async function dispatchMpvProtocolMessage( if (args.length > 0) { deps.emitClientMessage?.({ args }); } + } else if (msg.event === 'end-file') { + deps.emitEndFile?.({ + reason: typeof msg.reason === 'string' ? msg.reason : 'unknown', + fileError: + typeof msg.file_error === 'string' && msg.file_error.length > 0 ? msg.file_error : null, + }); } else if (msg.event === 'shutdown') { deps.restorePreviousSecondarySubVisibility(); if (deps.shouldQuitOnMpvShutdown()) { diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 3675fa0eb..e7cd2cf18 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -131,6 +131,7 @@ export interface MpvIpcClientEventMap { 'subtitle-metrics-change': { patch: Partial }; 'secondary-subtitle-visibility': { visible: boolean }; 'client-message': { args: string[] }; + 'end-file': { reason: string; fileError: string | null }; } type MpvIpcClientEventName = keyof MpvIpcClientEventMap; @@ -496,6 +497,9 @@ export class MpvIpcClient implements MpvClient { emitClientMessage: (payload) => { this.emit('client-message', payload); }, + emitEndFile: (payload) => { + this.emit('end-file', payload); + }, }; } diff --git a/src/main.ts b/src/main.ts index 3127398fe..a6ec0cc9e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3275,6 +3275,17 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({ }), sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), ensureMpvConnected: () => ensureMpvConnectedForPlayback(), + onPlaybackEndFile: (listener) => { + const client = appState.mpvClient; + if (!client) return () => {}; + client.on('end-file', listener); + return () => client.off('end-file', listener); + }, + readMpvProperty: (name) => { + const client = appState.mpvClient; + if (!client) return Promise.reject(new Error('mpv is not connected.')); + return client.requestProperty(name); + }, showVisibleOverlay: () => { // Launching a video turns a browse-only instance into a regular SubMiner // playback session: tray icon plus the overlay runtime that diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index e250b736f..4a79a3c9a 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -1,6 +1,18 @@ import { AnimeBridgeClient } from '../../anime-bridge/bridge-client'; import { resolveStream } from '../../anime-bridge/headers'; -import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url'; +import { + parseAnimeStatus, + resolveBridgeMediaUrl, + routeHlsThroughProxy, +} from '../../anime-bridge/media-url'; +import { + watchPlaybackOutcome, + type PlaybackEndFileEvent, +} from '../../anime-bridge/playback-outcome'; +import { + startStreamStripProxy, + type StreamStripProxyHandle, +} from '../../anime-bridge/stream-strip-proxy'; import { buildPlaybackCommands, buildTrackCommands, @@ -60,6 +72,14 @@ export interface AnimeBrowserRuntimeDeps { sendMpvCommand: (command: Array) => void; /** Brings mpv up if it is not already connected. Resolves false on failure. */ ensureMpvConnected: () => Promise; + /** + * Subscribe to mpv end-file events; returns the unsubscribe. Together with + * readMpvProperty this lets playEpisode confirm playback really started + * instead of reporting success the moment the commands were written. + */ + onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void; + /** One-shot mpv property read; rejects while the property is unavailable. */ + readMpvProperty?: (name: string) => Promise; showMpvOsd?: (message: string) => void; showVisibleOverlay?: () => void; /** Lets tests drive the pause between `loadfile` and the track commands. */ @@ -82,6 +102,7 @@ const TRACK_ATTACH_DELAY_MS = 300; export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { let bridgeState: AnimeBrowserBridgeState = IDLE_STATE; let sidecar: SidecarHandle | null = null; + let stripProxy: StreamStripProxyHandle | null = null; let starting: Promise | null = null; let extensions: InstalledExtension[] = []; let sources: ExtensionSource[] = []; @@ -125,6 +146,16 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { }); sidecar = handle; + try { + stripProxy = await startStreamStripProxy({ + upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl, + log: deps.log, + }); + } catch (error) { + // Playback still works for undisguised streams; log and carry on. + deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`); + } + await scanExtensions(handle); return handle; } @@ -470,10 +501,15 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { })), })); - const stream = selectPreferredStream(streams, deps.preferredQuality?.()); - if (!stream) { + const selected = selectPreferredStream(streams, deps.preferredQuality?.()); + if (!selected) { return { ok: false, error: 'That source returned no playable video.', quality: null }; } + // HLS goes through the local strip proxy, which undoes fake-image + // segment disguises mpv cannot decode around. + const stream = stripProxy + ? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, stripProxy.origin) } + : selected; if (!(await deps.ensureMpvConnected())) { return { @@ -483,29 +519,51 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { }; } - const title = `${request.animeTitle} — ${request.episodeName}`; - for (const command of buildPlaybackCommands({ stream, title })) { - deps.sendMpvCommand(command); - } - - const trackCommands = buildTrackCommands(stream); - if (trackCommands.length > 0) { - deps.log( - `[anime-browser] ${stream.audios.length} external audio, ` + - `${stream.subtitles.length} external subtitle track(s)`, - ); - // mpv attaches added tracks to the file that is loading, so give the - // loadfile a moment to take effect first. Same pause the Jellyfin - // subtitle preload uses. - await wait(TRACK_ATTACH_DELAY_MS); - for (const command of trackCommands) { + // Subscribed before loadfile so a fast failure cannot slip past it. + const watch = + deps.onPlaybackEndFile && deps.readMpvProperty + ? watchPlaybackOutcome({ + onEndFile: deps.onPlaybackEndFile, + readProperty: deps.readMpvProperty, + wait, + }) + : null; + + try { + const title = `${request.animeTitle} — ${request.episodeName}`; + for (const command of buildPlaybackCommands({ stream, title })) { deps.sendMpvCommand(command); } - } - deps.showVisibleOverlay?.(); - deps.showMpvOsd?.(title); - return { ok: true, error: null, quality: stream.quality || null }; + const trackCommands = buildTrackCommands(stream); + if (trackCommands.length > 0) { + deps.log( + `[anime-browser] ${stream.audios.length} external audio, ` + + `${stream.subtitles.length} external subtitle track(s)`, + ); + // mpv attaches added tracks to the file that is loading, so give the + // loadfile a moment to take effect first. Same pause the Jellyfin + // subtitle preload uses. + await wait(TRACK_ATTACH_DELAY_MS); + for (const command of trackCommands) { + deps.sendMpvCommand(command); + } + } + + if (watch) { + const outcome = await watch.wait(); + if (!outcome.ok) { + deps.log(`[anime-browser] playback failed to start: ${outcome.error}`); + return { ok: false, error: outcome.error, quality: null }; + } + } + + deps.showVisibleOverlay?.(); + deps.showMpvOsd?.(title); + return { ok: true, error: null, quality: stream.quality || null }; + } finally { + watch?.dispose(); + } } catch (error) { deps.log(`[anime-browser] playback failed: ${String(error)}`); return { ok: false, error: describeError(error), quality: null }; @@ -514,9 +572,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { async dispose(): Promise { const handle = sidecar; + const proxy = stripProxy; sidecar = null; + stripProxy = null; starting = null; setState(IDLE_STATE); + await proxy?.close(); await handle?.stop(); }, }; From ec9da6bed3e61184379768fa41eb906920c0d8bc Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 00:24:26 -0700 Subject: [PATCH 07/50] fix(anime): retry cold upstream fetches in the stream strip proxy Right after an episode resolves, the bridge (or the host behind it) can error on the very first segment fetches and be fine a moment later; mpv sweeps the playlist of failing segments in ~2s and gives up with "no audio or video data played" while a manual reload of the same URL plays. Retry a failed GET once after 400ms and log upstream error statuses so the next failure names the real cause in the app log. --- changes/anime-browser.md | 1 + src/anime-bridge/stream-strip-proxy.test.ts | 68 ++++++++++++++++++++- src/anime-bridge/stream-strip-proxy.ts | 51 +++++++++++++++- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/changes/anime-browser.md b/changes/anime-browser.md index b33b12134..980177630 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -14,6 +14,7 @@ area: anime - 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. - "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. diff --git a/src/anime-bridge/stream-strip-proxy.test.ts b/src/anime-bridge/stream-strip-proxy.test.ts index 5b16b9a0d..570684a56 100644 --- a/src/anime-bridge/stream-strip-proxy.test.ts +++ b/src/anime-bridge/stream-strip-proxy.test.ts @@ -80,7 +80,10 @@ async function withProxy( await new Promise((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 }); + const proxy = await startStreamStripProxy({ + upstreamOrigin: () => upstreamOrigin, + retryDelayMs: 5, + }); try { await run(proxy.origin, upstreamOrigin); } finally { @@ -163,3 +166,66 @@ test('proxy forwards error statuses without touching the body', async () => { }, ); }); + +/** + * 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, +): Promise { + 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((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((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); + }, + ); +}); diff --git a/src/anime-bridge/stream-strip-proxy.ts b/src/anime-bridge/stream-strip-proxy.ts index 5a83889d3..883acd058 100644 --- a/src/anime-bridge/stream-strip-proxy.ts +++ b/src/anime-bridge/stream-strip-proxy.ts @@ -68,8 +68,12 @@ 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; + export interface StreamStripProxyHandle { origin: string; port: number; @@ -97,6 +101,7 @@ export function startStreamStripProxy( options: StreamStripProxyOptions, ): Promise { 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') { @@ -114,19 +119,61 @@ export function startStreamStripProxy( const requestHeaders = forwardableHeaders(req.headers); delete requestHeaders.host; + 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 }, - (upstream) => handleUpstreamResponse(req, res, upstream), + (upstream) => { + 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); + }, ); 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, From ec66f59257068574213c0791ca24a0de7cd1428a Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 00:40:34 -0700 Subject: [PATCH 08/50] fix(anime): never forward Range to the bridge so 206 replies cannot skip the strip ffmpeg opens every HLS segment with 'Range: bytes=0-'. The bridge answers some of those 206 (depending on its cache state), and the proxy only rewrites full 200 bodies, so the PNG disguise passed through untouched and whether an episode played was a coin flip: first attempts died with "no audio or video data played", the same URL played fine once the bridge had the segments cached. Reproduced end to end against a private bridge + mpv driven with the app's exact commands; with Range dropped every segment is stripped and playback runs. --- changes/anime-browser.md | 1 + src/anime-bridge/stream-strip-proxy.test.ts | 36 +++++++++++++++++++++ src/anime-bridge/stream-strip-proxy.ts | 5 +++ 3 files changed, 42 insertions(+) diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 980177630..15904034b 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -15,6 +15,7 @@ area: anime - 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. - "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. diff --git a/src/anime-bridge/stream-strip-proxy.test.ts b/src/anime-bridge/stream-strip-proxy.test.ts index 570684a56..a0473346d 100644 --- a/src/anime-bridge/stream-strip-proxy.test.ts +++ b/src/anime-bridge/stream-strip-proxy.test.ts @@ -156,6 +156,42 @@ test('proxy rewrites absolute upstream playlist entries to its own origin', asyn } }); +test('proxy strips even when the client asks for a byte range', async () => { + // ffmpeg opens every HLS segment with `Range: bytes=0-`; the bridge answers + // some of those with 206, which must not bypass the strip. + const ts = makeTsPackets(8); + const disguised = Buffer.concat([PNG_HEADER, ts]); + const upstream = http.createServer((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); + }); + await new Promise((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 { + const response = await fetch(`${proxy.origin}/video/seg.ts`, { + headers: { Range: 'bytes=0-' }, + }); + const body = Buffer.from(await response.arrayBuffer()); + assert.equal(response.status, 200); + assert.deepEqual(body, ts); + } finally { + await proxy.close(); + await new Promise((resolve) => upstream.close(() => resolve())); + } +}); + test('proxy forwards error statuses without touching the body', async () => { await withProxy( { '/video/gone.ts': { status: 404, contentType: 'text/plain', body: Buffer.from('nope') } }, diff --git a/src/anime-bridge/stream-strip-proxy.ts b/src/anime-bridge/stream-strip-proxy.ts index 883acd058..214da2e9d 100644 --- a/src/anime-bridge/stream-strip-proxy.ts +++ b/src/anime-bridge/stream-strip-proxy.ts @@ -119,6 +119,11 @@ export function startStreamStripProxy( 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); From 4739c3da7831c252c3568dd79f7e12d3965b85e9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 01:05:40 -0700 Subject: [PATCH 09/50] fix(anime): restart the bridge on the next request after it dies The reported 'APK crash' turned out not to exist: repeated full scans of all installed extensions load cleanly. The real incident was the app's quit sequence stopping the bridge (its /stop endpoint) while requests were in flight - and it exposed that a bridge dying for any reason left the anime browser failing every call with a raw 'fetch failed' until the whole app was restarted. SidecarHandle now reports the child's exit (including to late subscribers), and the runtime detects a death it did not order: it drops the dead handle, closes the strip proxy, tells the UI, and the next request transparently starts a fresh bridge. requireBridge became an async bridge() that starts on demand, which also covers first use. Verified live: search -> SIGKILL the JVM -> search recovers. --- changes/anime-browser.md | 1 + src/anime-bridge/sidecar-process.test.ts | 39 ++++++++++++++++++ src/anime-bridge/sidecar-process.ts | 15 ++++++- src/main/runtime/anime-browser-runtime.ts | 50 ++++++++++++++++++----- 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 15904034b..66130f06d 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -16,6 +16,7 @@ area: anime - 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. diff --git a/src/anime-bridge/sidecar-process.test.ts b/src/anime-bridge/sidecar-process.test.ts index d9b292b78..1525e715d 100644 --- a/src/anime-bridge/sidecar-process.test.ts +++ b/src/anime-bridge/sidecar-process.test.ts @@ -1,5 +1,6 @@ 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'; @@ -64,3 +65,41 @@ test('an early exit is reported with its code rather than waiting out the deadli /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((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((resolve) => server.close(() => resolve())); + } +}); diff --git a/src/anime-bridge/sidecar-process.ts b/src/anime-bridge/sidecar-process.ts index 3423406a4..47a927602 100644 --- a/src/anime-bridge/sidecar-process.ts +++ b/src/anime-bridge/sidecar-process.ts @@ -35,6 +35,14 @@ export async function allocatePort(): Promise { 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; } @@ -129,7 +137,12 @@ export async function startSidecar(options: StartSidecarOptions): Promise { + void hasExited.then(() => listener(exited ?? { code: null, signal: null })); + }; + return { baseUrl, port, client, stop, onExit }; + } await delay(READY_POLL_INTERVAL_MS); } diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index 4a79a3c9a..a8ff7cabd 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -129,8 +129,17 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { return { ...toBridgeSource(extension, source.id), preferences: saved }; } - function requireBridge(): { client: AnimeBridgeClient; baseUrl: string } { - if (!sidecar) throw new Error('The anime bridge is not running yet.'); + /** + * The live bridge, starting (or restarting) it first when there is none. + * A bridge that died out from under the app — killed, crashed, stopped — + * comes back on the next request instead of failing every call until the + * app restarts. + */ + async function bridge(): Promise<{ client: AnimeBridgeClient; baseUrl: string }> { + if (!sidecar) await ensureBridge(); + if (!sidecar) { + throw new Error(bridgeState.message ?? 'The anime bridge is not running.'); + } return { client: sidecar.client, baseUrl: sidecar.baseUrl }; } @@ -146,6 +155,27 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { }); sidecar = handle; + // A deliberate stop detaches first (dispose nulls `sidecar` before + // stopping, a restart replaces it), so reaching the body means the bridge + // died out from under us and the next request should bring it back. + handle.onExit(({ code, signal }) => { + if (sidecar !== handle) return; + sidecar = null; + starting = null; + const proxy = stripProxy; + stripProxy = null; + void proxy?.close(); + deps.log( + `[anime-browser] bridge exited unexpectedly (code ${code}, signal ${signal}); ` + + 'it will restart on the next request', + ); + setState({ + stage: 'idle', + progress: null, + message: 'The anime bridge stopped. It restarts on the next search.', + }); + }); + try { stripProxy = await startStreamStripProxy({ upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl, @@ -256,7 +286,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { page: number, ) => Promise, ): Promise { - const { baseUrl } = requireBridge(); + const { baseUrl } = await bridge(); const targets = selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)]; if (targets.length === 0) throw new Error('No sources are installed.'); @@ -402,7 +432,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { * what it has no memory of between requests. */ async getPreferences(sourceId: string): Promise { - const { client } = requireBridge(); + const { client } = await bridge(); const source = requireSource(sourceId); const schema = await client.getSourcePreferences(await sourceFor(source.id)); return parsePreferences(schema); @@ -417,7 +447,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { key: string, value: string | string[] | boolean, ): Promise { - const { client } = requireBridge(); + const { client } = await bridge(); const source = await sourceFor(sourceId); // Start from the extension's own schema so saved values never go stale @@ -436,19 +466,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { }, async search(query: string, page = 1): Promise { - const { client } = requireBridge(); + const { client } = await bridge(); return browse(page, (source, requestedPage) => client.searchAnime(source, query, requestedPage), ); }, async getPopular(page = 1): Promise { - const { client } = requireBridge(); + const { client } = await bridge(); return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage)); }, async getDetails(animeUrl: string, sourceId?: string): Promise { - const { client, baseUrl } = requireBridge(); + const { client, baseUrl } = await bridge(); const source = requireSource(sourceId ?? selectedSourceId); const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl); return { @@ -461,7 +491,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { }, async getEpisodes(animeUrl: string, sourceId?: string): Promise { - const { client } = requireBridge(); + const { client } = await bridge(); const source = requireSource(sourceId ?? selectedSourceId); const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl); return episodes.map((episode) => ({ @@ -478,7 +508,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { async playEpisode(request: AnimeBrowserPlayRequest): Promise { try { - const { client, baseUrl } = requireBridge(); + const { client, baseUrl } = await bridge(); const videos = await client.getVideoList( await sourceFor(request.sourceId), request.episodeUrl, From 891c4a13d58d03fbf7c9e16436b21d5cf97f1a05 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 01:31:48 -0700 Subject: [PATCH 10/50] fix(overlay): fall back to did-finish-load when ready-to-show never fires A hidden overlay window stops producing frames once it is explicitly hidden and then reloaded (the Yomitan content-script reload right after startup), so ready-to-show never fires and the content-ready gate keeps the window hidden forever behind the Overlay loading spinner. Mark content ready from did-finish-load after a 1.5s grace period so the overlay always becomes showable; ready-to-show still wins when it fires. --- .../linux-overlay-content-ready-fallback.md | 4 ++ src/core/services/overlay-window.test.ts | 63 ++++++++++++++++++- src/core/services/overlay-window.ts | 49 ++++++++++++--- 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 changes/linux-overlay-content-ready-fallback.md diff --git a/changes/linux-overlay-content-ready-fallback.md b/changes/linux-overlay-content-ready-fallback.md new file mode 100644 index 000000000..e63a2278e --- /dev/null +++ b/changes/linux-overlay-content-ready-fallback.md @@ -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. diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index 2458f0174..04650cec0 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { ensureOverlayWindowLevel, updateOverlayWindowBounds } from './overlay-window'; +import { + ensureOverlayWindowLevel, + updateOverlayWindowBounds, + scheduleOverlayContentReadyFallback, + OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS, +} from './overlay-window'; import { handleOverlayWindowBeforeInputEvent, handleOverlayWindowBlurred, @@ -322,3 +327,59 @@ test('updateOverlayWindowBounds aligns Linux overlay content bounds to mpv geome assert.deepEqual(calls, [{ x: 0, y: -14, width: 3440, height: 1454 }]); }); + +test('scheduleOverlayContentReadyFallback marks content ready when ready-to-show never fired', () => { + const events: string[] = []; + const scheduled: Array<() => void> = []; + + scheduleOverlayContentReadyFallback({ + isContentReady: () => false, + isDestroyed: () => false, + markContentReady: () => events.push('mark'), + setTimeoutFn: (callback, delayMs) => { + events.push(`schedule:${delayMs}`); + scheduled.push(callback); + return null; + }, + }); + + assert.deepEqual(events, [`schedule:${OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS}`]); + scheduled[0]?.(); + assert.deepEqual(events, [`schedule:${OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS}`, 'mark']); +}); + +test('scheduleOverlayContentReadyFallback no-ops once content is already ready', () => { + const scheduled: Array<() => void> = []; + const events: string[] = []; + + scheduleOverlayContentReadyFallback({ + isContentReady: () => true, + isDestroyed: () => false, + markContentReady: () => events.push('mark'), + setTimeoutFn: (callback) => { + scheduled.push(callback); + return null; + }, + }); + + scheduled[0]?.(); + assert.deepEqual(events, []); +}); + +test('scheduleOverlayContentReadyFallback no-ops after the window is destroyed', () => { + const scheduled: Array<() => void> = []; + const events: string[] = []; + + scheduleOverlayContentReadyFallback({ + isContentReady: () => false, + isDestroyed: () => true, + markContentReady: () => events.push('mark'), + setTimeoutFn: (callback) => { + scheduled.push(callback); + return null; + }, + }); + + scheduled[0]?.(); + assert.deepEqual(events, []); +}); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index e074ed763..39713c4bb 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -80,6 +80,31 @@ export function updateOverlayWindowBounds( }); } +// ready-to-show is the primary content-ready signal, but it never fires when the +// page navigates while the window is hidden: an explicit hide() ends the +// paintWhenInitiallyHidden grace, the hidden widget stops producing frames, and +// the new document never paints. The Yomitan content-script reload right after +// startup hits exactly that, and content-ready gates showing the window, so +// without a fallback the overlay deadlocks behind the "Overlay loading" OSD. +export const OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS = 1500; + +export function scheduleOverlayContentReadyFallback(deps: { + isContentReady: () => boolean; + isDestroyed: () => boolean; + markContentReady: () => void; + setTimeoutFn?: (callback: () => void, delayMs: number) => unknown; + delayMs?: number; +}): void { + const setTimeoutFn = + deps.setTimeoutFn ?? ((callback: () => void, delayMs: number): unknown => setTimeout(callback, delayMs)); + setTimeoutFn(() => { + if (deps.isContentReady() || deps.isDestroyed()) { + return; + } + deps.markContentReady(); + }, deps.delayMs ?? OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS); +} + export function ensureOverlayWindowLevel(window: BrowserWindow): void { if (process.platform === 'darwin') { window.setAlwaysOnTop(true, 'screen-saver', 1); @@ -155,6 +180,17 @@ export function createOverlayWindow( logger.error('Page failed to load:', errorCode, errorDescription, validatedURL); }); + const markContentReady = (): void => { + if (isOverlayWindowContentReady(window)) { + return; + } + overlayWindowContentReady.add(window); + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] = true; + options.onWindowContentReady?.(); + }; + window.webContents.on('did-finish-load', () => { (window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[ OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG @@ -162,6 +198,11 @@ export function createOverlayWindow( window.setTitle(OVERLAY_WINDOW_TITLES[kind]); options.onRuntimeOptionsChanged(); options.onWindowDidFinishLoad?.(); + scheduleOverlayContentReadyFallback({ + isContentReady: () => isOverlayWindowContentReady(window), + isDestroyed: () => window.isDestroyed(), + markContentReady, + }); }); window.webContents.on('did-start-loading', () => { @@ -175,13 +216,7 @@ export function createOverlayWindow( window.setTitle(OVERLAY_WINDOW_TITLES[kind]); }); - window.once('ready-to-show', () => { - overlayWindowContentReady.add(window); - (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ - OVERLAY_WINDOW_CONTENT_READY_FLAG - ] = true; - options.onWindowContentReady?.(); - }); + window.once('ready-to-show', markContentReady); if (kind === 'visible') { window.webContents.on('devtools-opened', () => { From 2e54a3ac16f3d66332783f79b0a98dc484a2626e Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 01:38:54 -0700 Subject: [PATCH 11/50] style(animeui): tighten source picker and cue rail layout - Lay source picker label + input inline instead of stacked - Let detail cover keep natural aspect ratio instead of forced 2:3 crop - Widen cue rail gutter to match new cue number column width --- src/animeui/style.css | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/animeui/style.css b/src/animeui/style.css index 6f59b5535..eb8d8f285 100644 --- a/src/animeui/style.css +++ b/src/animeui/style.css @@ -207,8 +207,8 @@ body { .source-picker { display: flex; - flex-direction: column; - gap: 3px; + align-items: center; + gap: 8px; flex: none; min-width: 190px; } @@ -218,6 +218,12 @@ body { 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 ---------- */ @@ -446,9 +452,9 @@ body { .detail-cover { flex: none; + align-self: flex-start; width: clamp(140px, 18vw, 232px); - aspect-ratio: 2 / 3; - object-fit: cover; + height: auto; border-radius: 12px; border: 1px solid var(--line); background: var(--ctp-crust); @@ -536,7 +542,7 @@ body { .cue-rail::before { content: ''; position: absolute; - left: 41px; + left: 48px; top: 6px; bottom: 6px; width: 1px; @@ -546,8 +552,8 @@ body { .cue { position: relative; display: grid; - grid-template-columns: 40px 1fr; - gap: 14px; + grid-template-columns: 34px 1fr; + gap: 26px; align-items: baseline; width: 100%; padding: 9px 10px 9px 0; @@ -564,7 +570,7 @@ body { .cue::after { content: ''; position: absolute; - left: 37px; + left: 43px; top: 15px; width: 7px; height: 7px; From 2fbf27faffcfae6f5b26fc73145a07e8c1cb8364 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 01:50:19 -0700 Subject: [PATCH 12/50] fix(anime): report a failed bridge kill instead of a generic timeout - Track kill errors separately from exit/spawn so a signal failure (e.g. EPERM) surfaces its own message instead of a bare "did not exit after SIGKILL" - Attach stop() failures as the cause on the readiness-timeout error so a surviving child's kill failure isn't dropped - Skip stop() entirely when spawn itself failed, and stop polling once the ready deadline has passed - Add stopTimeoutMs test hook and a test covering a kill that fails without the child exiting --- src/anime-bridge/sidecar-process.test.ts | 29 +++++++++++++- src/anime-bridge/sidecar-process.ts | 48 ++++++++++++++++++------ 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/anime-bridge/sidecar-process.test.ts b/src/anime-bridge/sidecar-process.test.ts index 1525e715d..b24cd4357 100644 --- a/src/anime-bridge/sidecar-process.test.ts +++ b/src/anime-bridge/sidecar-process.test.ts @@ -11,12 +11,17 @@ const binaries: BundleBinaries = { jarPath: '/tmp/MExtensionServer.jar', }; -/** A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches. */ -function fakeChild(onKill?: (child: EventEmitter) => void): ChildProcess { +/** + * 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; @@ -52,6 +57,26 @@ test('a readiness timeout shuts the child down and reports the timeout', async ( ); }); +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(); diff --git a/src/anime-bridge/sidecar-process.ts b/src/anime-bridge/sidecar-process.ts index 47a927602..0a335bfdb 100644 --- a/src/anime-bridge/sidecar-process.ts +++ b/src/anime-bridge/sidecar-process.ts @@ -51,6 +51,8 @@ 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; } @@ -83,6 +85,8 @@ export async function startSidecar(options: StartSidecarOptions): Promise((resolve) => { child.once('exit', (code, signal) => { exited = { code, signal }; @@ -90,16 +94,24 @@ export async function startSidecar(options: StartSidecarOptions): Promise { - spawnError = error; - if (exited === null) exited = { code: null, signal: null }; - resolve(); + // 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 => { 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) }); @@ -110,14 +122,20 @@ export async function startSidecar(options: StartSidecarOptions): Promise {}); - throw new Error( + 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; } From d1d128b3f27495dfaaac43c9b2d1ec2a15a95819 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 02:32:25 -0700 Subject: [PATCH 13/50] feat(anime): cache stream subtitles to disk for the alass reference - Download a stream's subtitle tracks to a temp dir and load them into mpv as files instead of streaming from the source URL, so Subsync can use them as the alass reference (mirrors the Jellyfin subtitle preload) - Detect format from content first, falling back to the URL extension - A track that fails to download falls back to its URL so the episode still plays - Remove the temp dir when the next episode loads or the runtime disposes --- CHANGELOG.md | 4 + src/anime-bridge/subtitle-cache.test.ts | 166 ++++++++++++++++ src/anime-bridge/subtitle-cache.ts | 223 ++++++++++++++++++++++ src/main/runtime/anime-browser-runtime.ts | 69 ++++++- 4 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 src/anime-bridge/subtitle-cache.test.ts create mode 100644 src/anime-bridge/subtitle-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4284da94d..c7f4cb64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Anime Browser Language Filter: The Extensions tab's available list now has a language chip row above it. Pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter — selecting a language replaces "All" rather than sitting beside it. Extension rows name the language ("Japanese" instead of `ja`), and the Available heading shows how many of the offered extensions the filter leaves. +### Changed + +- Anime Browser Subtitles: A stream's subtitle tracks are now downloaded to a temp directory and loaded into mpv as files instead of streamed from the source URL, so they can serve as the alass reference in Subsync (the same way Jellyfin subtitles do) — a streamed track had no file on disk and was rejected by the source picker. 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. + ### Fixed - Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and, on macOS, puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback. diff --git a/src/anime-bridge/subtitle-cache.test.ts b/src/anime-bridge/subtitle-cache.test.ts new file mode 100644 index 000000000..148917d8d --- /dev/null +++ b/src/anime-bridge/subtitle-cache.test.ts @@ -0,0 +1,166 @@ +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; + removed: string[]; + requests: Array<{ url: string; headers: Record }>; +} + +function fakeIo(bodies: Record): FakeIo { + const written = new Map(); + const removed: string[] = []; + const requests: Array<{ url: string; headers: Record }> = []; + + 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('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); +}); diff --git a/src/anime-bridge/subtitle-cache.ts b/src/anime-bridge/subtitle-cache.ts new file mode 100644 index 000000000..76af17323 --- /dev/null +++ b/src/anime-bridge/subtitle-cache.ts @@ -0,0 +1,223 @@ +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; + arrayBuffer: () => Promise; +} + +export interface SubtitleCacheIo { + fetch: ( + url: string, + init: { headers: Record; signal: AbortSignal }, + ) => Promise; + makeTempDir: (prefix: string) => Promise; + writeFile: (filePath: string, bytes: Uint8Array) => Promise; + removeDir: (dir: string) => Promise; +} + +export interface CacheSubtitleTracksOptions { + tracks: SubtitleTrackRef[]; + /** Headers the stream was resolved with; some hosts gate subtitles too. */ + headers?: Record; + 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) && /]|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(); + 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, +): Promise { + 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 = new Uint8Array(await response.arrayBuffer()); + } 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; +} + +/** + * 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 { + 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 => { + 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 { + if (!dir) return; + try { + await io.removeDir(dir); + } catch {} +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index a8ff7cabd..e239af571 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -26,6 +26,11 @@ import { type ExtensionSource, type InstalledExtension, } from '../../anime-bridge/extension-store'; +import { + cacheSubtitleTracks, + removeSubtitleCache, + type SubtitleCacheIo, +} from '../../anime-bridge/subtitle-cache'; import { interleave, mapSourcesConcurrently } from '../../anime-bridge/multi-source-search'; import { startSidecar, type SidecarHandle } from '../../anime-bridge/sidecar-process'; import { @@ -84,6 +89,8 @@ export interface AnimeBrowserRuntimeDeps { showVisibleOverlay?: () => void; /** Lets tests drive the pause between `loadfile` and the track commands. */ wait?: (ms: number) => Promise; + /** Overrides the filesystem/network the subtitle cache uses. Tests only. */ + subtitleCacheIo?: SubtitleCacheIo; onBridgeState: (state: AnimeBrowserBridgeState) => void; /** * Streams per-source progress while a search invoke is pending. Optional so @@ -110,10 +117,54 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { let loadFailures: ExtensionLoadFailure[] = []; // Monotonic; identifies the newest browse so stale ones stop emitting. let searchToken = 0; + // Temp directory holding the playing episode's downloaded subtitles. Kept + // until the next episode replaces it, because alass reads it mid-playback. + let subtitleCacheDir: string | null = null; const preferenceStore = new PreferenceStore(deps.preferencesFile); const wait = deps.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + /** + * Drop the previous episode's downloaded subtitles. + * + * Deliberately not done at end-file: alass reads those files for as long as + * the episode is up, so they only go once another one replaces them. + */ + async function clearSubtitleCache(): Promise { + const previousDir = subtitleCacheDir; + subtitleCacheDir = null; + await removeSubtitleCache(previousDir, deps.subtitleCacheIo); + } + + /** + * Download the stream's subtitle tracks so mpv loads files instead of URLs. + * + * alass needs the reference track on disk, and the subsync picker rejects an + * external track whose `external-filename` is not a real file, so a streamed + * track cannot be used to retime anything. + */ + async function cacheStreamSubtitles(stream: { + headers: Record; + subtitles: Array<{ url: string; lang: string }>; + }): Promise> { + const cached = await cacheSubtitleTracks({ + tracks: stream.subtitles, + headers: stream.headers, + io: deps.subtitleCacheIo, + log: deps.log, + }); + subtitleCacheDir = cached.dir; + + const localCount = cached.tracks.filter((track) => track.local).length; + if (cached.tracks.length > 0) { + deps.log( + `[anime-browser] cached ${localCount}/${cached.tracks.length} subtitle track(s) to disk` + + (cached.dir ? ` in ${cached.dir}` : ''), + ); + } + return cached.tracks.map((track) => ({ url: track.url, lang: track.lang })); + } + function setState(state: AnimeBrowserBridgeState): void { bridgeState = state; deps.onBridgeState(state); @@ -564,18 +615,23 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { for (const command of buildPlaybackCommands({ stream, title })) { deps.sendMpvCommand(command); } + // The old episode's subtitles are dead the moment this one loads, + // whether or not the new one brings any of its own. + await clearSubtitleCache(); - const trackCommands = buildTrackCommands(stream); - if (trackCommands.length > 0) { + if (stream.audios.length > 0 || stream.subtitles.length > 0) { deps.log( `[anime-browser] ${stream.audios.length} external audio, ` + `${stream.subtitles.length} external subtitle track(s)`, ); // mpv attaches added tracks to the file that is loading, so give the // loadfile a moment to take effect first. Same pause the Jellyfin - // subtitle preload uses. - await wait(TRACK_ATTACH_DELAY_MS); - for (const command of trackCommands) { + // subtitle preload uses. The download runs inside that pause. + const [subtitles] = await Promise.all([ + cacheStreamSubtitles(stream), + wait(TRACK_ATTACH_DELAY_MS), + ]); + for (const command of buildTrackCommands({ ...stream, subtitles })) { deps.sendMpvCommand(command); } } @@ -603,10 +659,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { async dispose(): Promise { const handle = sidecar; const proxy = stripProxy; + const cacheDir = subtitleCacheDir; sidecar = null; stripProxy = null; starting = null; + subtitleCacheDir = null; setState(IDLE_STATE); + await removeSubtitleCache(cacheDir, deps.subtitleCacheIo); await proxy?.close(); await handle?.stop(); }, From feb8d5a55cb3a7533750d2402fc6d9f5ea6d4219 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 03:13:48 -0700 Subject: [PATCH 14/50] fix(anime): cap outcome polling and upstream stalls by wall clock - watchPlaybackOutcome now tracks a real deadline so slow property reads eat the timeout budget instead of extending it, and a zero probe interval still terminates - stream strip proxy aborts upstream GETs that go silent for 15s instead of hanging mpv on that segment forever - anime browser runtime drops a stream proxy that finishes starting after its bridge already died, instead of leaking a listener pointed at nothing --- src/anime-bridge/playback-outcome.test.ts | 46 +++++++++- src/anime-bridge/playback-outcome.ts | 13 ++- src/anime-bridge/stream-strip-proxy.test.ts | 98 ++++++++++----------- src/anime-bridge/stream-strip-proxy.ts | 14 ++- src/main/runtime/anime-browser-runtime.ts | 7 +- 5 files changed, 120 insertions(+), 58 deletions(-) diff --git a/src/anime-bridge/playback-outcome.test.ts b/src/anime-bridge/playback-outcome.test.ts index 606409079..c56beb0c5 100644 --- a/src/anime-bridge/playback-outcome.test.ts +++ b/src/anime-bridge/playback-outcome.test.ts @@ -7,12 +7,25 @@ type Harness = { 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; }; -function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: 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(); const failing = new Set(); + const readCostMs = overrides?.readCostMs ?? 0; + let clock = 0; const watch = watchPlaybackOutcome({ onEndFile: (listener) => { @@ -20,10 +33,14 @@ function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: numbe 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 () => {}, + wait: async (ms) => { + clock += ms; + }, + now: () => clock, timeoutMs: overrides?.timeoutMs ?? 1000, probeIntervalMs: overrides?.probeIntervalMs ?? 100, }); @@ -35,6 +52,7 @@ function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: numbe listenerCount: () => listeners.size, setProperty: (name, value) => properties.set(name, value), failProperty: (name) => failing.add(name), + elapsed: () => clock, }; return { watch, harness }; } @@ -68,10 +86,32 @@ test('ignores the end-file fired for the file being replaced', async () => { }); test('times out with a failure when nothing ever starts', async () => { - const { watch } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 }); + 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(); }); diff --git a/src/anime-bridge/playback-outcome.ts b/src/anime-bridge/playback-outcome.ts index dd4731e52..c703ca7e2 100644 --- a/src/anime-bridge/playback-outcome.ts +++ b/src/anime-bridge/playback-outcome.ts @@ -26,6 +26,8 @@ export interface WatchPlaybackOutcomeDeps { /** One-shot mpv property read; may reject while the file is still loading. */ readProperty: (name: string) => Promise; wait: (ms: number) => Promise; + /** Injectable clock; the timeout is wall-clock, not a probe count. */ + now?: () => number; timeoutMs?: number; probeIntervalMs?: number; } @@ -58,7 +60,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu }); async function wait(): Promise { - for (let elapsed = 0; elapsed < timeoutMs; elapsed += probeIntervalMs) { + // 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 }; @@ -66,7 +72,10 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu // The property is unreadable while mpv is between files; keep polling. } if (failure) return failure; - await deps.wait(probeIntervalMs); + // 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 ?? { diff --git a/src/anime-bridge/stream-strip-proxy.test.ts b/src/anime-bridge/stream-strip-proxy.test.ts index a0473346d..452aebc81 100644 --- a/src/anime-bridge/stream-strip-proxy.test.ts +++ b/src/anime-bridge/stream-strip-proxy.test.ts @@ -64,11 +64,16 @@ test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lin 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, + routes: Record | http.RequestListener, run: (proxyOrigin: string, upstreamOrigin: string) => Promise, ): Promise { 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'); @@ -133,63 +138,54 @@ test('proxy leaves non-TS bodies alone', async () => { }); test('proxy rewrites absolute upstream playlist entries to its own origin', async () => { - const upstream = http.createServer((req, res) => { - const origin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; - if (req.url === '/video/list.m3u8') { + 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,\n${origin}/video/abs.ts\n`); - return; - } - res.writeHead(404).end(); - }); - await new Promise((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 }); - try { - const { body } = await fetchBytes(`${proxy.origin}/video/list.m3u8`); - const text = body.toString('utf8'); - assert.ok(text.includes(`${proxy.origin}/video/abs.ts`)); - assert.ok(!text.includes(upstreamOrigin)); - } finally { - await proxy.close(); - await new Promise((resolve) => upstream.close(() => resolve())); - } + 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 bridge answers - // some of those with 206, which must not bypass the strip. + // 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]); - const upstream = http.createServer((req, res) => { - if (req.headers.range !== undefined) { - res.writeHead(206, { - 'content-type': 'image/png', - 'content-range': `bytes 0-${disguised.length - 1}/${disguised.length}`, - }); + 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); - return; - } - res.writeHead(200, { 'content-type': 'image/png' }); - res.end(disguised); - }); - await new Promise((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 { - const response = await fetch(`${proxy.origin}/video/seg.ts`, { - headers: { Range: 'bytes=0-' }, - }); - const body = Buffer.from(await response.arrayBuffer()); - assert.equal(response.status, 200); - assert.deepEqual(body, ts); - } finally { - await proxy.close(); - await new Promise((resolve) => upstream.close(() => resolve())); - } + }, + 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 () => { diff --git a/src/anime-bridge/stream-strip-proxy.ts b/src/anime-bridge/stream-strip-proxy.ts index 214da2e9d..00ab50240 100644 --- a/src/anime-bridge/stream-strip-proxy.ts +++ b/src/anime-bridge/stream-strip-proxy.ts @@ -73,6 +73,12 @@ export interface StreamStripProxyOptions { } 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; @@ -152,8 +158,10 @@ export function startStreamStripProxy( const upstreamRequest = http.request( upstreamUrl, - { method: req.method, headers: requestHeaders }, + { 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) { @@ -167,6 +175,10 @@ export function startStreamStripProxy( 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`); diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index e239af571..6ebc1ef08 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -228,10 +228,15 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { }); try { - stripProxy = await startStreamStripProxy({ + const proxy = await startStreamStripProxy({ upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl, log: deps.log, }); + // The bridge can die while the proxy is coming up, in which case onExit + // already ran and cleared `stripProxy`; adopting this one would leak a + // listening server pointed at a dead upstream. + if (sidecar === handle) stripProxy = proxy; + else void proxy.close(); } catch (error) { // Playback still works for undisguised streams; log and carry on. deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`); From 4abbe8d5672566710705fb6a29f1500c8f6e270c Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 17:20:45 -0700 Subject: [PATCH 15/50] fix(subsync): sync subtitles from stream URLs, auto-discover tool paths - Download URL-loaded subtitle tracks (Aniyomi extension streams, Jellyfin) to a temp file first, reusing mpv's own request headers, instead of rejecting them with "Subtitle file not found" - Pass mpv's headers through to ffmpeg for internal track extraction from streams too - Auto-discover alass/ffsubsync/ffmpeg on PATH and common install prefixes when the config path is empty, instead of a hard-coded /usr/bin fallback that never exists on macOS - Log subsync failures to the app log, not just a toast that vanishes in seconds - Update docs-site and README credits for the new behavior --- CHANGELOG.md | 3 + README.md | 32 +-- docs-site/configuration.md | 10 +- docs-site/troubleshooting.md | 17 +- src/anki-integration/media-source.ts | 110 +---------- src/core/services/ipc-command.ts | 5 + src/core/services/mpv-http-headers.ts | 173 ++++++++++++++++ .../services/secondary-subtitle-sidecar.ts | 63 +----- src/core/services/subsync-extract.test.ts | 175 ++++++++++++++++ src/core/services/subsync-extract.ts | 163 +++++++++++++++ src/core/services/subsync-remote.test.ts | 121 ++++++++++++ src/core/services/subsync.ts | 186 +++++++----------- src/subsync/executables.test.ts | 79 ++++++++ src/subsync/executables.ts | 91 +++++++++ src/subsync/utils.ts | 37 ++-- 15 files changed, 944 insertions(+), 321 deletions(-) create mode 100644 src/core/services/mpv-http-headers.ts create mode 100644 src/core/services/subsync-extract.test.ts create mode 100644 src/core/services/subsync-extract.ts create mode 100644 src/core/services/subsync-remote.test.ts create mode 100644 src/subsync/executables.test.ts create mode 100644 src/subsync/executables.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f4cb64b..acc5de089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ ### Fixed +- Subtitle Sync On Streams: Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. Such a 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 now be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`. +- Subsync Tool Discovery: 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/`, 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 Logging: Subsync failures are now written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from. - Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and, on macOS, puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback. - Anime Browser Playback Session: Launching a video from the anime browser now starts a regular SubMiner session (tray icon plus the on-demand overlay runtime), and in `subminer anime` standalone mode, closing the browser window during playback no longer quits the app and kills the stream. The window can be reopened from the tray while mpv keeps playing. diff --git a/README.md b/README.md index b3fef966e..d5f0deb28 100644 --- a/README.md +++ b/README.md @@ -256,19 +256,27 @@ 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) / [Mangayomi](https://github.com/kodjodevf/mangayomi) | Reference implementation for the extension bridge protocol | +| [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) +(Mozilla Public License 2.0), downloaded at runtime rather than bundled. MPL-2.0 is +compatible with the GPL. Aniyomi and Mihon are Apache-2.0; Mangatan and Mangayomi are +GPL-3.0. SubMiner ships no extension repositories and bundles no extensions. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 6726c5b3e..1be70ba1b 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1214,11 +1214,15 @@ Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The | 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 `_retimed.`. | +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`. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index e88b010b7..8abb47878 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -209,21 +209,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"** @@ -234,6 +239,12 @@ 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. + ## TsukiHime **"xz binary not found"** diff --git a/src/anki-integration/media-source.ts b/src/anki-integration/media-source.ts index 8f511bc9f..c00bb65d6 100644 --- a/src/anki-integration/media-source.ts +++ b/src/anki-integration/media-source.ts @@ -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, 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, - name: string, -): Promise { - if (!mpvClient.requestProperty) { - return null; - } - - try { - return await mpvClient.requestProperty(name); - } catch { - return null; - } -} - -async function requestFirstNonEmptyStringProperty( - mpvClient: Pick, - names: readonly string[], -): Promise { - for (const name of names) { - const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name)); - if (value) { - return value; - } - } - return null; -} - async function resolveRemoteInputOptions( mpvClient: Pick, resolvedPath: string, @@ -268,24 +179,7 @@ async function resolveRemoteInputOptions( return undefined; } - const headers: Record = {}; - 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'); diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 1cad075d7..4fed0a63f 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -4,6 +4,9 @@ import { SubsyncManualRunRequest, SubsyncResult, } from '../../types'; +import { createLogger } from '../../logger'; + +const logger = createLogger('subsync'); export interface HandleMpvCommandFromIpcOptions { specialCommands: { @@ -193,6 +196,8 @@ export async function runSubsyncManualFromIpc( return result; } catch (error) { const message = `Subsync failed: ${(error as Error).message}`; + // An OSD toast is the only other trace of this, and it is gone in seconds. + logger.error(message, error); options.showMpvOsd(message); return { ok: false, message }; } finally { diff --git a/src/core/services/mpv-http-headers.ts b/src/core/services/mpv-http-headers.ts new file mode 100644 index 000000000..092f7ad09 --- /dev/null +++ b/src/core/services/mpv-http-headers.ts @@ -0,0 +1,173 @@ +/** + * Read back the HTTP request options mpv is using for the current stream. + * + * Anything SubMiner fetches out-of-band — ffmpeg extractions, subtitle + * downloads — has to speak to the origin the same way mpv did, or an + * authenticated or referer-gated host answers 403 to us while playback works. + */ + +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; + +export interface MpvHttpPropertySource { + requestProperty?: (name: string) => Promise; +} + +export interface ResolvedMpvHttpHeaders { + headers: Record; + userAgent: string | null; +} + +function trimToNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + 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; +} + +export function setHeaderIfMissing( + headers: Record, + 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( + client: MpvHttpPropertySource, + name: string, +): Promise { + if (!client.requestProperty) { + return null; + } + + try { + return await client.requestProperty(name); + } catch { + return null; + } +} + +async function requestFirstNonEmptyStringProperty( + client: MpvHttpPropertySource, + names: readonly string[], +): Promise { + for (const name of names) { + const value = trimToNonEmptyString(await requestOptionalMpvProperty(client, name)); + if (value) { + return value; + } + } + return null; +} + +export async function resolveMpvHttpHeaders( + client: MpvHttpPropertySource, +): Promise { + const headers: Record = {}; + if (!client.requestProperty) { + return { headers, userAgent: null }; + } + + for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) { + const mpvHeaderFields = toHeaderFields(await requestOptionalMpvProperty(client, propertyName)); + for (const field of mpvHeaderFields) { + const parsed = parseMpvHeaderField(field); + if (parsed) { + headers[parsed[0]] = parsed[1]; + } + } + } + + const userAgent = await requestFirstNonEmptyStringProperty(client, USER_AGENT_PROPERTY_NAMES); + const referrer = await requestFirstNonEmptyStringProperty(client, REFERRER_PROPERTY_NAMES); + if (referrer) { + setHeaderIfMissing(headers, 'Referer', referrer); + } + + return { headers, userAgent }; +} + +/** Flatten to the header map a plain `http.get` wants. */ +export function toRequestHeaders(resolved: ResolvedMpvHttpHeaders | null): Record { + if (!resolved) return {}; + const headers = { ...resolved.headers }; + if (resolved.userAgent) { + setHeaderIfMissing(headers, 'User-Agent', resolved.userAgent); + } + return headers; +} + +/** + * ffmpeg input options carrying the same request context. These configure the + * HTTP demuxer, so they must precede `-i` on the command line. + */ +export function toFfmpegInputHttpArgs(resolved: ResolvedMpvHttpHeaders | null): string[] { + if (!resolved) return []; + + const args: string[] = []; + if (resolved.userAgent) { + args.push('-user_agent', resolved.userAgent); + } + const fields = Object.entries(resolved.headers); + if (fields.length > 0) { + args.push('-headers', fields.map(([name, value]) => `${name}: ${value}\r\n`).join('')); + } + return args; +} diff --git a/src/core/services/secondary-subtitle-sidecar.ts b/src/core/services/secondary-subtitle-sidecar.ts index 988db6f73..151692474 100644 --- a/src/core/services/secondary-subtitle-sidecar.ts +++ b/src/core/services/secondary-subtitle-sidecar.ts @@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } import os from 'node:os'; import path from 'node:path'; import { runCommand, type CommandResult } from '../../subsync/utils'; +import { resolveExecutable, SUBSYNC_EXECUTABLE_NAMES } from '../../subsync/executables'; import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js'; import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js'; @@ -11,13 +12,6 @@ const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']); const TIMING_TOLERANCE_SECONDS = 0.25; const SAME_TIMING_EPSILON_SECONDS = 0.001; const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000; -const FALLBACK_ALASS_PATHS = [ - '/opt/homebrew/bin/alass-cli', - '/opt/homebrew/bin/alass', - '/usr/local/bin/alass-cli', - '/usr/local/bin/alass', - '/usr/bin/alass', -]; type SidecarCandidate = { path: string; @@ -74,61 +68,8 @@ function expandPreferredLanguages( return unique(expanded); } -function isExecutableFile(filePath: string): boolean { - try { - return statSync(filePath).isFile(); - } catch { - return false; - } -} - -function pathEntries(): string[] { - const entries = (process.env.PATH ?? '') - .split(path.delimiter) - .map((entry) => entry.trim()) - .filter(Boolean); - return unique([...entries, ...FALLBACK_ALASS_PATHS.map((candidate) => path.dirname(candidate))]); -} - -function executableNames(name: string): string[] { - if (process.platform !== 'win32') return [name]; - const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT') - .split(';') - .map((entry) => entry.trim()) - .filter(Boolean); - if (path.extname(name)) return [name]; - return [name, ...extensions.map((extension) => `${name}${extension}`)]; -} - -function findExecutable(names: readonly string[]): string { - for (const name of names) { - if (path.dirname(name) !== '.') { - return isExecutableFile(name) ? name : ''; - } - } - - for (const dir of pathEntries()) { - for (const name of names) { - for (const executableName of executableNames(name)) { - const candidate = path.join(dir, executableName); - if (isExecutableFile(candidate)) return candidate; - } - } - } - - for (const candidate of FALLBACK_ALASS_PATHS) { - if (isExecutableFile(candidate)) return candidate; - } - - return ''; -} - function resolveAlassPath(configuredPath: string | null | undefined): string { - const trimmed = configuredPath?.trim() ?? ''; - if (trimmed) { - return findExecutable([trimmed]); - } - return findExecutable(['alass', 'alass-cli']); + return resolveExecutable(configuredPath, SUBSYNC_EXECUTABLE_NAMES.alass); } function fileSignature(filePath: string): string | null { diff --git a/src/core/services/subsync-extract.test.ts b/src/core/services/subsync-extract.test.ts new file mode 100644 index 000000000..5fdd939d3 --- /dev/null +++ b/src/core/services/subsync-extract.test.ts @@ -0,0 +1,175 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { cleanupTemporaryFile, extractSubtitleTrackToFile } from './subsync-extract'; +import type { ResolvedMpvHttpHeaders } from './mpv-http-headers'; + +const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n'; + +interface StubServer { + url: (pathname: string) => string; + requests: Array<{ url: string; headers: http.IncomingHttpHeaders }>; + close: () => Promise; +} + +async function startSubtitleServer(): Promise { + const requests: StubServer['requests'] = []; + const server = http.createServer((req, res) => { + requests.push({ url: req.url ?? '', headers: req.headers }); + if (req.url?.startsWith('/forbidden')) { + res.writeHead(403); + res.end('nope'); + return; + } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(SUBTITLE_BODY); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + return { + url: (pathname) => `http://127.0.0.1:${port}${pathname}`, + requests, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +test('extractSubtitleTrackToFile downloads an external track served over http', async () => { + const server = await startSubtitleServer(); + try { + const result = await extractSubtitleTrackToFile({ + ffmpegPath: '/unused/ffmpeg', + videoPath: server.url('/stream.mp4'), + track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') }, + httpHeaders: null, + }); + + assert.equal(result.temporary, true); + assert.equal(path.extname(result.path), '.srt'); + assert.equal(fs.readFileSync(result.path, 'utf8'), SUBTITLE_BODY); + + cleanupTemporaryFile(result); + assert.equal(fs.existsSync(result.path), false); + } finally { + await server.close(); + } +}); + +test('extractSubtitleTrackToFile forwards mpv request headers to the subtitle host', async () => { + const server = await startSubtitleServer(); + const httpHeaders: ResolvedMpvHttpHeaders = { + headers: { Referer: 'https://example.test/watch' }, + userAgent: 'SubMinerTest/1.0', + }; + + try { + const result = await extractSubtitleTrackToFile({ + ffmpegPath: '/unused/ffmpeg', + videoPath: server.url('/stream.mp4'), + track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') }, + httpHeaders, + }); + cleanupTemporaryFile(result); + + const sent = server.requests.at(-1); + assert.equal(sent?.headers.referer, 'https://example.test/watch'); + assert.equal(sent?.headers['user-agent'], 'SubMinerTest/1.0'); + } finally { + await server.close(); + } +}); + +test('extractSubtitleTrackToFile reports the HTTP status when the download fails', async () => { + const server = await startSubtitleServer(); + try { + await assert.rejects( + extractSubtitleTrackToFile({ + ffmpegPath: '/unused/ffmpeg', + videoPath: server.url('/stream.mp4'), + track: { + id: 2, + type: 'sub', + external: true, + 'external-filename': server.url('/forbidden/ja.srt'), + }, + httpHeaders: null, + }), + /Failed to download subtitle track.*403/s, + ); + } finally { + await server.close(); + } +}); + +test('extractSubtitleTrackToFile still uses a local external track in place', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-')); + const localPath = path.join(dir, 'local.srt'); + fs.writeFileSync(localPath, SUBTITLE_BODY); + + try { + const result = await extractSubtitleTrackToFile({ + ffmpegPath: '/unused/ffmpeg', + videoPath: path.join(dir, 'video.mkv'), + track: { id: 2, type: 'sub', external: true, 'external-filename': localPath }, + httpHeaders: null, + }); + + assert.deepEqual(result, { path: localPath, temporary: false }); + // A borrowed file must survive cleanup. + cleanupTemporaryFile(result); + assert.equal(fs.existsSync(localPath), true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('extractSubtitleTrackToFile rejects a missing local external track', async () => { + await assert.rejects( + extractSubtitleTrackToFile({ + ffmpegPath: '/unused/ffmpeg', + videoPath: '/tmp/video.mkv', + track: { id: 2, type: 'sub', external: true, 'external-filename': '/tmp/does-not-exist.srt' }, + httpHeaders: null, + }), + /Subtitle file not found/, + ); +}); + +test('cleanupTemporaryFile preserves the retimed output sharing the temp directory', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-')); + const sourcePath = path.join(dir, 'remote_track.srt'); + const retimedPath = path.join(dir, 'remote_track_retimed.srt'); + fs.writeFileSync(sourcePath, SUBTITLE_BODY); + fs.writeFileSync(retimedPath, SUBTITLE_BODY); + + try { + cleanupTemporaryFile({ path: sourcePath, temporary: true }, retimedPath); + + assert.equal(fs.existsSync(sourcePath), false); + assert.equal(fs.existsSync(retimedPath), true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('cleanupTemporaryFile keeps the file when it is itself the preserved output', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-')); + const inPlacePath = path.join(dir, 'remote_track.srt'); + fs.writeFileSync(inPlacePath, SUBTITLE_BODY); + + try { + cleanupTemporaryFile({ path: inPlacePath, temporary: true }, inPlacePath); + + assert.equal(fs.existsSync(inPlacePath), true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/subsync-extract.ts b/src/core/services/subsync-extract.ts new file mode 100644 index 000000000..31870e507 --- /dev/null +++ b/src/core/services/subsync-extract.ts @@ -0,0 +1,163 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + codecToExtension, + fileExists, + MpvTrack, + runCommand, + summarizeCommandFailure, +} from '../../subsync/utils'; +import { downloadToFile, isRemoteMediaPath } from '../../jimaku/utils'; +import { createLogger } from '../../logger'; +import { + ResolvedMpvHttpHeaders, + toFfmpegInputHttpArgs, + toRequestHeaders, +} from './mpv-http-headers'; + +const logger = createLogger('subsync'); + +export interface FileExtractionResult { + path: string; + temporary: boolean; +} + +export interface SubtitleExtractionInput { + ffmpegPath: string; + videoPath: string; + track: MpvTrack; + /** mpv's request context, so stream-hosted tracks stay reachable. */ + httpHeaders: ResolvedMpvHttpHeaders | null; +} + +function extensionForUrl(url: string, fallback: string): string { + try { + const parsed = new URL(url); + const ext = path.extname(parsed.pathname).replace(/^\./, '').toLowerCase(); + return /^[a-z0-9]{1,5}$/.test(ext) ? ext : fallback; + } catch { + return fallback; + } +} + +/** + * Pull a subtitle track mpv loaded from a URL down to disk. + * + * Extension-backed and Jellyfin streams add their subtitles by URL, and neither + * alass nor ffmpeg can read one — the whole subsync path used to reject these + * with "Subtitle file not found: https://…". + */ +async function downloadRemoteSubtitleTrack( + url: string, + httpHeaders: ResolvedMpvHttpHeaders | null, +): Promise { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-')); + const outputPath = path.join(tempDir, `remote_track.${extensionForUrl(url, 'srt')}`); + + const result = await downloadToFile(url, outputPath, toRequestHeaders(httpHeaders)); + if (!result.ok) { + throw new Error(`Failed to download subtitle track: ${result.error?.error ?? 'unknown error'}`); + } + if (!fileExists(outputPath)) { + throw new Error(`Downloaded subtitle track is missing: ${url}`); + } + + logger.info(`Downloaded remote subtitle track to ${outputPath}`); + return { path: outputPath, temporary: true }; +} + +async function extractInternalTrack(input: SubtitleExtractionInput): Promise { + const ffIndex = input.track['ff-index']; + const extension = codecToExtension(input.track.codec); + if (typeof ffIndex !== 'number' || !Number.isInteger(ffIndex) || ffIndex < 0) { + throw new Error('Internal subtitle track has no valid ff-index'); + } + if (!extension) { + throw new Error(`Unsupported subtitle codec: ${input.track.codec ?? 'unknown'}`); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-')); + const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); + // Header args configure the HTTP demuxer, so they belong before `-i`. + const httpArgs = isRemoteMediaPath(input.videoPath) + ? toFfmpegInputHttpArgs(input.httpHeaders) + : []; + + const extraction = await runCommand(input.ffmpegPath, [ + '-hide_banner', + '-nostdin', + '-y', + '-loglevel', + 'error', + ...httpArgs, + '-an', + '-vn', + '-i', + input.videoPath, + '-map', + `0:${ffIndex}`, + '-f', + extension, + outputPath, + ]); + + if (!extraction.ok || !fileExists(outputPath)) { + throw new Error( + `Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure( + 'ffmpeg', + extraction, + )}`, + ); + } + + return { path: outputPath, temporary: true }; +} + +export async function extractSubtitleTrackToFile( + input: SubtitleExtractionInput, +): Promise { + if (input.track.external) { + const externalPath = input.track['external-filename']; + if (typeof externalPath !== 'string' || externalPath.length === 0) { + throw new Error('External subtitle track has no file path'); + } + if (isRemoteMediaPath(externalPath)) { + return downloadRemoteSubtitleTrack(externalPath, input.httpHeaders); + } + if (!fileExists(externalPath)) { + throw new Error(`Subtitle file not found: ${externalPath}`); + } + return { path: externalPath, temporary: false }; + } + + return extractInternalTrack(input); +} + +/** + * Drop a temporary extraction once alass/ffsubsync is done with it. + * + * `preservePath` is the retimed subtitle mpv is about to load. With + * `replace: false` it lands in this same temp directory, so the removal has to + * skip it — and the directory removal must stay non-recursive, since that is + * what keeps the retimed file alive for mpv. + */ +export function cleanupTemporaryFile( + extraction: FileExtractionResult, + preservePath?: string, +): void { + if (!extraction.temporary) return; + if (preservePath && path.resolve(preservePath) === path.resolve(extraction.path)) return; + + try { + if (fileExists(extraction.path)) { + fs.unlinkSync(extraction.path); + } + } catch {} + try { + const dir = path.dirname(extraction.path); + if (fs.existsSync(dir)) { + fs.rmdirSync(dir); + } + } catch {} +} diff --git a/src/core/services/subsync-remote.test.ts b/src/core/services/subsync-remote.test.ts new file mode 100644 index 000000000..ce2fb9351 --- /dev/null +++ b/src/core/services/subsync-remote.test.ts @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { runSubsyncManual } from './subsync'; +import type { TriggerSubsyncFromConfigDeps } from './subsync'; + +const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n'; + +/** + * Extension-backed and Jellyfin playback add subtitles by URL, which subsync + * used to reject outright with "Subtitle file not found: https://…". + */ +async function startStubHost(): Promise<{ + url: (pathname: string) => string; + close: () => Promise; +}> { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(SUBTITLE_BODY); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + return { + url: (pathname) => `http://127.0.0.1:${port}${pathname}`, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +test('runSubsyncManual syncs stream subtitle tracks served over http', async (t) => { + if (process.platform === 'win32') { + t.skip('stub shell scripts are not executable on Windows'); + return; + } + + const host = await startStubHost(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-remote-')); + const alassLogPath = path.join(tmpDir, 'alass-args.log'); + const alassPath = path.join(tmpDir, 'alass.sh'); + + // Record argv and copy the inputs aside — subsync deletes its temp files as + // soon as the run finishes — then write the third argument (alass's output). + fs.writeFileSync( + alassPath, + `#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\ncp "$1" "${tmpDir}/reference.copy"\ncp "$2" "${tmpDir}/target.copy"\nprintf '%s' "retimed" > "$3"\nexit 0\n`, + { mode: 0o755 }, + ); + + const primaryUrl = host.url('/subs/ja.srt'); + const sourceUrl = host.url('/subs/en.srt'); + const sentCommands: Array> = []; + + const deps: Pick = { + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: (payload) => { + sentCommands.push(payload.command); + }, + requestProperty: async (name: string) => { + if (name === 'path') return host.url('/stream/video.mp4'); + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { + id: 1, + type: 'sub', + selected: true, + external: true, + lang: 'ja', + 'external-filename': primaryUrl, + }, + { + id: 2, + type: 'sub', + selected: false, + external: true, + lang: 'en', + 'external-filename': sourceUrl, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + alassPath, + ffsubsyncPath: '', + ffmpegPath: '', + replace: true, + }), + }; + + try { + const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps); + + assert.equal(result.ok, true, result.message); + assert.equal(result.message, 'Subtitle synchronized with alass'); + + const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n'); + // reference, target, output — both inputs were pulled down from the host. + assert.equal(alassArgs.length, 3); + assert.equal(fs.readFileSync(path.join(tmpDir, 'reference.copy'), 'utf8'), SUBTITLE_BODY); + assert.equal(fs.readFileSync(path.join(tmpDir, 'target.copy'), 'utf8'), SUBTITLE_BODY); + + const loadCommand = sentCommands.find((command) => command[0] === 'sub-add'); + assert.equal(loadCommand?.[1], alassArgs[2]); + assert.equal(fs.readFileSync(String(loadCommand?.[1]), 'utf8'), 'retimed'); + } finally { + await host.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index c7c54d5ff..600bc4fc7 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -1,45 +1,37 @@ -import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types'; import { CommandResult, - codecToExtension, fileExists, formatTrackLabel, getTrackById, - hasPathSeparators, MpvTrack, runCommand, + summarizeCommandFailure, SubsyncContext, SubsyncResolvedConfig, } from '../../subsync/utils'; +import { + resolveExecutable, + SubsyncExecutable, + SUBSYNC_EXECUTABLE_NAMES, +} from '../../subsync/executables'; +import { + cleanupTemporaryFile, + extractSubtitleTrackToFile, + FileExtractionResult, +} from './subsync-extract'; +import { resolveMpvHttpHeaders, ResolvedMpvHttpHeaders } from './mpv-http-headers'; import { isRemoteMediaPath } from '../../jimaku/utils'; +import { createLogger } from '../../logger'; -interface FileExtractionResult { - path: string; - temporary: boolean; -} +const logger = createLogger('subsync'); type SubtitleSlot = 'primary' | 'secondary'; const SYNCED_TRACK_LOOKUP_ATTEMPTS = 5; const SYNCED_TRACK_LOOKUP_RETRY_MS = 100; -function summarizeCommandFailure(command: string, result: CommandResult): string { - const parts = [ - `code=${result.code ?? 'n/a'}`, - result.stderr ? `stderr: ${result.stderr}` : '', - result.stdout ? `stdout: ${result.stdout}` : '', - result.error ? `error: ${result.error}` : '', - ] - .map((value) => value.trim()) - .filter(Boolean); - - if (parts.length === 0) return `command failed (${command})`; - return `command failed (${command}) ${parts.join(' | ')}`; -} - interface MpvClientLike { connected: boolean; currentAudioStreamIndex: number | null; @@ -178,86 +170,19 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise { - if (track.external) { - const externalPath = track['external-filename']; - if (typeof externalPath !== 'string' || externalPath.length === 0) { - throw new Error('External subtitle track has no file path'); - } - if (!fileExists(externalPath)) { - throw new Error(`Subtitle file not found: ${externalPath}`); - } - return { path: externalPath, temporary: false }; - } - - const ffIndex = track['ff-index']; - const extension = codecToExtension(track.codec); - if (typeof ffIndex !== 'number' || !Number.isInteger(ffIndex) || ffIndex < 0) { - throw new Error('Internal subtitle track has no valid ff-index'); - } - if (!extension) { - throw new Error(`Unsupported subtitle codec: ${track.codec ?? 'unknown'}`); - } - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-')); - const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); - const extraction = await runCommand(ffmpegPath, [ - '-hide_banner', - '-nostdin', - '-y', - '-loglevel', - 'error', - '-an', - '-vn', - '-i', - videoPath, - '-map', - `0:${ffIndex}`, - '-f', - extension, - outputPath, - ]); - - if (!extraction.ok || !fileExists(outputPath)) { - throw new Error( - `Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure( - 'ffmpeg', - extraction, - )}`, - ); - } - - return { path: outputPath, temporary: true }; -} - -function cleanupTemporaryFile(extraction: FileExtractionResult): void { - if (!extraction.temporary) return; - try { - if (fileExists(extraction.path)) { - fs.unlinkSync(extraction.path); - } - } catch {} - try { - const dir = path.dirname(extraction.path); - if (fs.existsSync(dir)) { - fs.rmdirSync(dir); - } - } catch {} +/** + * Resolve a sync tool, discovering it on PATH (plus the usual install prefixes) + * when the config leaves it unset. + */ +function resolveSubsyncExecutable(configuredPath: string, name: SubsyncExecutable): string { + const resolved = resolveExecutable(configuredPath, SUBSYNC_EXECUTABLE_NAMES[name]); + if (resolved) return resolved; + + throw new Error( + configuredPath.trim() + ? `Configured ${name} executable not found: ${configuredPath}` + : `Could not find ${name}. Install it or set subsync.${name}_path in your config.`, + ); } function buildRetimedPath(subPath: string, replace: boolean): string { @@ -366,23 +291,28 @@ async function subsyncToReference( resolved: SubsyncResolvedConfig, client: MpvClientLike, slot: SubtitleSlot, + httpHeaders: ResolvedMpvHttpHeaders | null, ): Promise { - const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); - const targetExtraction = await extractSubtitleTrackToFile( + const ffmpegPath = resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'); + const targetExtraction = await extractSubtitleTrackToFile({ ffmpegPath, - context.videoPath, - targetTrack, - ); + videoPath: context.videoPath, + track: targetTrack, + httpHeaders, + }); const replaceTarget = resolved.replace !== false && !targetExtraction.temporary; const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget); + logger.info( + `Running ${engine}: target=${targetExtraction.path} reference=${referenceFilePath} output=${outputPath}`, + ); try { let result: CommandResult; if (engine === 'alass') { - const alassPath = ensureExecutablePath(resolved.alassPath, 'alass'); + const alassPath = resolveSubsyncExecutable(resolved.alassPath, 'alass'); result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath); } else { - const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync'); + const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync'); result = await runFfsubsyncSync( ffsubsyncPath, context.videoPath, @@ -394,6 +324,7 @@ async function subsyncToReference( if (!result.ok || !fileExists(outputPath)) { const details = summarizeCommandFailure(engine, result); + logger.error(`${engine} synchronization failed: ${details}`); return { ok: false, message: `${engine} synchronization failed: ${details}`, @@ -401,12 +332,13 @@ async function subsyncToReference( } await loadSyncedSubtitle(client, outputPath, slot); + logger.info(`${engine} synchronization succeeded; loaded ${outputPath}`); return { ok: true, message: `Subtitle synchronized with ${engine}`, }; } finally { - cleanupTemporaryFile(targetExtraction); + cleanupTemporaryFile(targetExtraction, outputPath); } } @@ -444,6 +376,18 @@ export async function runSubsyncManual( const client = getMpvClientForSubsync(deps); const context = await gatherSubsyncContext(client); const resolved = deps.getResolvedConfig(); + // Only streams need mpv's request context, and asking costs four round-trips. + const httpHeaders = isRemoteMediaPath(context.videoPath) + ? await resolveMpvHttpHeaders(client) + : null; + + logger.info( + `Manual subsync requested: engine=${request.engine} referenceMode=${ + request.referenceMode ?? 'track' + } referenceTrackId=${request.referenceTrackId ?? 'none'} targetTrackId=${ + request.targetTrackId ?? 'none' + } remote=${httpHeaders !== null}`, + ); const targetTrack = resolveTargetTrack(request, context); if (!targetTrack) { @@ -455,10 +399,9 @@ export async function runSubsyncManual( try { validateFfsubsyncReference(context.videoPath); } catch (error) { - return { - ok: false, - message: `ffsubsync synchronization failed: ${(error as Error).message}`, - }; + const message = `ffsubsync synchronization failed: ${(error as Error).message}`; + logger.error(message); + return { ok: false, message }; } return subsyncToReference( 'ffsubsync', @@ -468,6 +411,7 @@ export async function runSubsyncManual( resolved, client, targetSlot, + httpHeaders, ); } @@ -487,25 +431,28 @@ export async function runSubsyncManual( resolved, client, targetSlot, + httpHeaders, ); } const referenceTrack = getTrackById(context.subtitleTracks, request.referenceTrackId ?? null); if (!referenceTrack) { + logger.warn('Manual subsync rejected: no alass reference track selected'); return { ok: false, message: 'Select a reference subtitle track for alass' }; } if (referenceTrack.id === targetTrack.id) { return { ok: false, message: 'Reference and out-of-sync subtitles must be different tracks' }; } - const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); + const ffmpegPath = resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'); let referenceExtraction: FileExtractionResult | null = null; try { - referenceExtraction = await extractSubtitleTrackToFile( + referenceExtraction = await extractSubtitleTrackToFile({ ffmpegPath, - context.videoPath, - referenceTrack, - ); + videoPath: context.videoPath, + track: referenceTrack, + httpHeaders, + }); return await subsyncToReference( 'alass', referenceExtraction.path, @@ -514,6 +461,7 @@ export async function runSubsyncManual( resolved, client, targetSlot, + httpHeaders, ); } finally { if (referenceExtraction) { diff --git a/src/subsync/executables.test.ts b/src/subsync/executables.test.ts new file mode 100644 index 000000000..061bcd6ff --- /dev/null +++ b/src/subsync/executables.test.ts @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { findExecutable, resolveExecutable, SUBSYNC_EXECUTABLE_NAMES } from './executables'; +import { getSubsyncConfig } from './utils'; + +function withTempBin(run: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-executables-')); + const previousPath = process.env.PATH; + try { + process.env.PATH = dir; + run(dir); + } finally { + process.env.PATH = previousPath; + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function writeExecutable(dir: string, name: string): string { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + return filePath; +} + +test('getSubsyncConfig leaves unset tool paths empty instead of guessing /usr/bin', () => { + const resolved = getSubsyncConfig({ alass_path: '', ffsubsync_path: '', ffmpeg_path: '' }); + + assert.equal(resolved.alassPath, ''); + assert.equal(resolved.ffsubsyncPath, ''); + assert.equal(resolved.ffmpegPath, ''); +}); + +test('getSubsyncConfig trims configured paths', () => { + const resolved = getSubsyncConfig({ alass_path: ' /opt/homebrew/bin/alass-cli ' }); + + assert.equal(resolved.alassPath, '/opt/homebrew/bin/alass-cli'); +}); + +test('resolveExecutable discovers alass-cli on PATH when config is empty', () => { + withTempBin((dir) => { + const expected = writeExecutable(dir, 'alass-cli'); + + assert.equal(resolveExecutable('', SUBSYNC_EXECUTABLE_NAMES.alass), expected); + assert.equal(resolveExecutable(undefined, SUBSYNC_EXECUTABLE_NAMES.alass), expected); + }); +}); + +test('resolveExecutable prefers alass over alass-cli when both exist', () => { + withTempBin((dir) => { + const expected = writeExecutable(dir, 'alass'); + writeExecutable(dir, 'alass-cli'); + + assert.equal(resolveExecutable('', SUBSYNC_EXECUTABLE_NAMES.alass), expected); + }); +}); + +test('resolveExecutable honours an explicit path and does not fall back when it is missing', () => { + withTempBin((dir) => { + writeExecutable(dir, 'alass'); + + assert.equal(resolveExecutable('/nope/alass', SUBSYNC_EXECUTABLE_NAMES.alass), ''); + }); +}); + +test('resolveExecutable treats a bare configured name as a PATH lookup', () => { + withTempBin((dir) => { + const expected = writeExecutable(dir, 'ffmpeg'); + + assert.equal(resolveExecutable('ffmpeg', SUBSYNC_EXECUTABLE_NAMES.ffmpeg), expected); + }); +}); + +test('findExecutable returns empty when nothing matches', () => { + withTempBin(() => { + assert.equal(findExecutable(['definitely-not-a-real-binary-xyz']), ''); + }); +}); diff --git a/src/subsync/executables.ts b/src/subsync/executables.ts new file mode 100644 index 000000000..2f0aaee4c --- /dev/null +++ b/src/subsync/executables.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * A GUI launch inherits a minimal PATH that omits the package-manager prefixes + * users actually install these tools under, so PATH alone cannot find them. + * Probing the usual prefixes is what makes "leave the config empty and we will + * discover it" true rather than aspirational. + */ +const FALLBACK_BIN_DIRS = [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/opt/local/bin', + '/usr/bin', + '/bin', +]; + +/** Names each tool ships under, in the order they should be preferred. */ +export const SUBSYNC_EXECUTABLE_NAMES = { + alass: ['alass', 'alass-cli'], + ffsubsync: ['ffsubsync'], + ffmpeg: ['ffmpeg'], +} as const; + +export type SubsyncExecutable = keyof typeof SUBSYNC_EXECUTABLE_NAMES; + +function unique(values: string[]): string[] { + return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index); +} + +export function isExecutableFile(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +function searchDirectories(): string[] { + const entries = (process.env.PATH ?? '') + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); + return unique([...entries, ...FALLBACK_BIN_DIRS]); +} + +function executableNames(name: string): string[] { + if (process.platform !== 'win32') return [name]; + const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT') + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean); + if (path.extname(name)) return [name]; + return [name, ...extensions.map((extension) => `${name}${extension}`)]; +} + +/** + * Resolve the first of `names` that exists, returning '' when none do. + * + * A name carrying a directory component is taken literally — the caller spelled + * out a path, so falling back to a same-named binary elsewhere would silently + * run something they did not point at. + */ +export function findExecutable(names: readonly string[]): string { + for (const name of names) { + if (path.dirname(name) !== '.') { + return isExecutableFile(name) ? name : ''; + } + } + + for (const dir of searchDirectories()) { + for (const name of names) { + for (const executableName of executableNames(name)) { + const candidate = path.join(dir, executableName); + if (isExecutableFile(candidate)) return candidate; + } + } + } + + return ''; +} + +/** Resolve a configured tool path, discovering it when the config is empty. */ +export function resolveExecutable( + configuredPath: string | null | undefined, + names: readonly string[], +): string { + const trimmed = configuredPath?.trim() ?? ''; + if (trimmed) return findExecutable([trimmed]); + return findExecutable(names); +} diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts index dcca36f56..2d21de42f 100644 --- a/src/subsync/utils.ts +++ b/src/subsync/utils.ts @@ -23,12 +23,6 @@ export interface SubsyncResolvedConfig { replace?: boolean; } -const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = { - alass: '/usr/bin/alass', - ffsubsync: '/usr/bin/ffsubsync', - ffmpeg: '/usr/bin/ffmpeg', -} as const; - export interface SubsyncContext { videoPath: string; primaryTrack: MpvTrack; @@ -82,22 +76,35 @@ function resolveCommandInvocation( return { command: executable, args }; } +/** + * An unset path stays empty here on purpose: hard-coding `/usr/bin/` made + * the documented "leave empty to auto-discover from PATH" a lie and broke every + * default-config macOS install, where none of these live in /usr/bin. + * Discovery happens at run time in `resolveSubsyncExecutable`. + */ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig { - const resolvePath = (value: string | undefined, fallback: string): string => { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : fallback; - }; + const trim = (value: string | undefined): string => value?.trim() ?? ''; return { - alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass), - ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync), - ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg), + alassPath: trim(config?.alass_path), + ffsubsyncPath: trim(config?.ffsubsync_path), + ffmpegPath: trim(config?.ffmpeg_path), replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace, }; } -export function hasPathSeparators(value: string): boolean { - return value.includes('/') || value.includes('\\'); +export function summarizeCommandFailure(command: string, result: CommandResult): string { + const parts = [ + `code=${result.code ?? 'n/a'}`, + result.stderr ? `stderr: ${result.stderr}` : '', + result.stdout ? `stdout: ${result.stdout}` : '', + result.error ? `error: ${result.error}` : '', + ] + .map((value) => value.trim()) + .filter(Boolean); + + if (parts.length === 0) return `command failed (${command})`; + return `command failed (${command}) ${parts.join(' | ')}`; } export function fileExists(pathOrEmpty: string): boolean { From 035ff8e7bfc8482ed03c79e35c0a1bc2cee4df63 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 17:35:59 -0700 Subject: [PATCH 16/50] fix(subsync): resolve ffmpeg only when a track needs extraction Stream subtitles arrive as URLs and are downloaded, never piped through ffmpeg, so demanding an ffmpeg install up front broke stream syncs (and CI, which has no ffmpeg). Resolution is now lazy, scoped to embedded tracks. --- .../services/__tests__/stats-server.test.ts | 4 +- src/core/services/mpv-http-headers.ts | 5 + src/core/services/subsync-extract.test.ts | 34 +++-- src/core/services/subsync-extract.ts | 117 +++++++++++------- src/core/services/subsync-remote.test.ts | 5 +- src/core/services/subsync.ts | 6 +- src/subsync/executables.ts | 10 +- src/subsync/utils.ts | 16 ++- 8 files changed, 136 insertions(+), 61 deletions(-) diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index a7c2a5dd1..b427d0227 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -2028,7 +2028,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 @@ -2088,7 +2088,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 diff --git a/src/core/services/mpv-http-headers.ts b/src/core/services/mpv-http-headers.ts index 092f7ad09..8dcd6b424 100644 --- a/src/core/services/mpv-http-headers.ts +++ b/src/core/services/mpv-http-headers.ts @@ -7,16 +7,21 @@ */ const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']); +// Least specific first: every list is merged in order and later entries +// overwrite earlier ones, so the file-local headers win. const HTTP_HEADER_FIELD_PROPERTY_NAMES = [ 'http-header-fields', 'options/http-header-fields', 'file-local-options/http-header-fields', ] as const; +// Most specific first: these resolve through requestFirstNonEmptyStringProperty, +// which stops at the first match, so the file-local value has to lead. const USER_AGENT_PROPERTY_NAMES = [ 'file-local-options/user-agent', 'options/user-agent', 'user-agent', ] as const; +// Most specific first, for the same first-match reason as the user agent. const REFERRER_PROPERTY_NAMES = [ 'file-local-options/referrer', 'options/referrer', diff --git a/src/core/services/subsync-extract.test.ts b/src/core/services/subsync-extract.test.ts index 5fdd939d3..5b1eff05d 100644 --- a/src/core/services/subsync-extract.test.ts +++ b/src/core/services/subsync-extract.test.ts @@ -46,9 +46,16 @@ test('extractSubtitleTrackToFile downloads an external track served over http', const server = await startSubtitleServer(); try { const result = await extractSubtitleTrackToFile({ - ffmpegPath: '/unused/ffmpeg', + resolveFfmpegPath: () => { + throw new Error('external tracks must not resolve ffmpeg'); + }, videoPath: server.url('/stream.mp4'), - track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') }, + track: { + id: 2, + type: 'sub', + external: true, + 'external-filename': server.url('/subs/ja.srt'), + }, httpHeaders: null, }); @@ -72,9 +79,16 @@ test('extractSubtitleTrackToFile forwards mpv request headers to the subtitle ho try { const result = await extractSubtitleTrackToFile({ - ffmpegPath: '/unused/ffmpeg', + resolveFfmpegPath: () => { + throw new Error('external tracks must not resolve ffmpeg'); + }, videoPath: server.url('/stream.mp4'), - track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') }, + track: { + id: 2, + type: 'sub', + external: true, + 'external-filename': server.url('/subs/ja.srt'), + }, httpHeaders, }); cleanupTemporaryFile(result); @@ -92,7 +106,9 @@ test('extractSubtitleTrackToFile reports the HTTP status when the download fails try { await assert.rejects( extractSubtitleTrackToFile({ - ffmpegPath: '/unused/ffmpeg', + resolveFfmpegPath: () => { + throw new Error('external tracks must not resolve ffmpeg'); + }, videoPath: server.url('/stream.mp4'), track: { id: 2, @@ -116,7 +132,9 @@ test('extractSubtitleTrackToFile still uses a local external track in place', as try { const result = await extractSubtitleTrackToFile({ - ffmpegPath: '/unused/ffmpeg', + resolveFfmpegPath: () => { + throw new Error('external tracks must not resolve ffmpeg'); + }, videoPath: path.join(dir, 'video.mkv'), track: { id: 2, type: 'sub', external: true, 'external-filename': localPath }, httpHeaders: null, @@ -134,7 +152,9 @@ test('extractSubtitleTrackToFile still uses a local external track in place', as test('extractSubtitleTrackToFile rejects a missing local external track', async () => { await assert.rejects( extractSubtitleTrackToFile({ - ffmpegPath: '/unused/ffmpeg', + resolveFfmpegPath: () => { + throw new Error('external tracks must not resolve ffmpeg'); + }, videoPath: '/tmp/video.mkv', track: { id: 2, type: 'sub', external: true, 'external-filename': '/tmp/does-not-exist.srt' }, httpHeaders: null, diff --git a/src/core/services/subsync-extract.ts b/src/core/services/subsync-extract.ts index 31870e507..c4754a3fe 100644 --- a/src/core/services/subsync-extract.ts +++ b/src/core/services/subsync-extract.ts @@ -24,7 +24,11 @@ export interface FileExtractionResult { } export interface SubtitleExtractionInput { - ffmpegPath: string; + /** + * Resolved lazily: an external track never shells out to ffmpeg, so a stream + * whose subtitles arrive by URL must not fail just because ffmpeg is absent. + */ + resolveFfmpegPath: () => string; videoPath: string; track: MpvTrack; /** mpv's request context, so stream-hosted tracks stay reachable. */ @@ -41,6 +45,19 @@ function extensionForUrl(url: string, fallback: string): string { } } +/** + * Drop a scratch directory whose extraction never completed. + * + * Removal is recursive here — unlike `cleanupTemporaryFile`, nothing in a failed + * directory is worth keeping, and a half-written download would otherwise leave + * the directory behind for the life of the machine. + */ +function discardTemporaryDirectory(tempDir: string): void { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch {} +} + /** * Pull a subtitle track mpv loaded from a URL down to disk. * @@ -53,18 +70,25 @@ async function downloadRemoteSubtitleTrack( httpHeaders: ResolvedMpvHttpHeaders | null, ): Promise { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-')); - const outputPath = path.join(tempDir, `remote_track.${extensionForUrl(url, 'srt')}`); + try { + const outputPath = path.join(tempDir, `remote_track.${extensionForUrl(url, 'srt')}`); - const result = await downloadToFile(url, outputPath, toRequestHeaders(httpHeaders)); - if (!result.ok) { - throw new Error(`Failed to download subtitle track: ${result.error?.error ?? 'unknown error'}`); - } - if (!fileExists(outputPath)) { - throw new Error(`Downloaded subtitle track is missing: ${url}`); - } + const result = await downloadToFile(url, outputPath, toRequestHeaders(httpHeaders)); + if (!result.ok) { + throw new Error( + `Failed to download subtitle track: ${result.error?.error ?? 'unknown error'}`, + ); + } + if (!fileExists(outputPath)) { + throw new Error(`Downloaded subtitle track is missing: ${url}`); + } - logger.info(`Downloaded remote subtitle track to ${outputPath}`); - return { path: outputPath, temporary: true }; + logger.info(`Downloaded remote subtitle track to ${outputPath}`); + return { path: outputPath, temporary: true }; + } catch (error) { + discardTemporaryDirectory(tempDir); + throw error; + } } async function extractInternalTrack(input: SubtitleExtractionInput): Promise { @@ -78,40 +102,45 @@ async function extractInternalTrack(input: SubtitleExtractionInput): Promise ({ alassPath, ffsubsyncPath: '', - ffmpegPath: '', + // Points nowhere on purpose: both tracks are external, so resolving ffmpeg + // at all would throw. An empty path would just auto-discover the real + // ffmpeg on a dev machine and prove nothing. + ffmpegPath: path.join(tmpDir, 'no-such-ffmpeg'), replace: true, }), }; diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index 600bc4fc7..886da5271 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -293,9 +293,8 @@ async function subsyncToReference( slot: SubtitleSlot, httpHeaders: ResolvedMpvHttpHeaders | null, ): Promise { - const ffmpegPath = resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'); const targetExtraction = await extractSubtitleTrackToFile({ - ffmpegPath, + resolveFfmpegPath: () => resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'), videoPath: context.videoPath, track: targetTrack, httpHeaders, @@ -444,11 +443,10 @@ export async function runSubsyncManual( return { ok: false, message: 'Reference and out-of-sync subtitles must be different tracks' }; } - const ffmpegPath = resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'); let referenceExtraction: FileExtractionResult | null = null; try { referenceExtraction = await extractSubtitleTrackToFile({ - ffmpegPath, + resolveFfmpegPath: () => resolveSubsyncExecutable(resolved.ffmpegPath, 'ffmpeg'), videoPath: context.videoPath, track: referenceTrack, httpHeaders, diff --git a/src/subsync/executables.ts b/src/subsync/executables.ts index 2f0aaee4c..5f45cec35 100644 --- a/src/subsync/executables.ts +++ b/src/subsync/executables.ts @@ -28,9 +28,17 @@ function unique(values: string[]): string[] { return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index); } +/** + * A same-named non-executable file earlier on PATH would otherwise shadow the + * real binary and surface as a spawn EACCES rather than "keep looking". Windows + * has no execute bit, so the regular-file check is all it can offer there. + */ export function isExecutableFile(filePath: string): boolean { try { - return fs.statSync(filePath).isFile(); + if (!fs.statSync(filePath).isFile()) return false; + if (process.platform === 'win32') return true; + fs.accessSync(filePath, fs.constants.X_OK); + return true; } catch { return false; } diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts index 2d21de42f..f7edb4a43 100644 --- a/src/subsync/utils.ts +++ b/src/subsync/utils.ts @@ -93,11 +93,23 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso }; } +/** + * ffsubsync streams a progress bar to stderr, so an unbounded summary reaches + * the OSD (and the error log) as tens of kilobytes of carriage returns. The tail + * is the part that says what went wrong. + */ +const COMMAND_OUTPUT_SUMMARY_LIMIT = 2000; + +function tailForSummary(value: string): string { + if (value.length <= COMMAND_OUTPUT_SUMMARY_LIMIT) return value; + return `…${value.slice(-COMMAND_OUTPUT_SUMMARY_LIMIT)}`; +} + export function summarizeCommandFailure(command: string, result: CommandResult): string { const parts = [ `code=${result.code ?? 'n/a'}`, - result.stderr ? `stderr: ${result.stderr}` : '', - result.stdout ? `stdout: ${result.stdout}` : '', + result.stderr ? `stderr: ${tailForSummary(result.stderr)}` : '', + result.stdout ? `stdout: ${tailForSummary(result.stdout)}` : '', result.error ? `error: ${result.error}` : '', ] .map((value) => value.trim()) From c94830531c70519b9c09fe3cce1372130bcf994e Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 23:34:23 -0700 Subject: [PATCH 17/50] fix(anime): thread episode metadata through stream playback and fix seas - Split anime browser stream titles into series/season/episode fields (episode-metadata.ts) instead of one joined string, so stats grouping, the mpv title, and the Jimaku/TsukiHime modals all get real values instead of a filename-derived "m3u8" - Fix AniList season resolver to walk through split-cour sequels and specials without counting them as a season, and to surface rate-limit/network failures instead of falling through to a wrong air-order guess - Add a Season field to the TsukiHime modal and include later seasons in its search query - Rewrite WebVTT subtitle tracks as SRT before handing them to alass, since alass has no VTT support and streams serve VTT - Update CHANGELOG and troubleshooting docs --- CHANGELOG.md | 7 + docs-site/troubleshooting.md | 2 + src/anime-bridge/episode-metadata.test.ts | 140 ++++++++ src/anime-bridge/episode-metadata.ts | 205 +++++++++++ src/animeui/animeui.ts | 1 + src/core/services/anilist/anilist-updater.ts | 3 +- .../services/anilist/cover-art-fetcher.ts | 3 +- .../services/anilist/season-resolver.test.ts | 327 ++++++++++++++++++ src/core/services/anilist/season-resolver.ts | 152 +++++++- .../immersion-tracker-service.test.ts | 113 ++++++ .../services/immersion-tracker-service.ts | 148 ++++++-- src/core/services/subsync-alass-input.test.ts | 113 ++++++ src/core/services/subsync-alass-input.ts | 155 +++++++++ src/core/services/subsync.test.ts | 79 +++++ src/core/services/subsync.ts | 35 +- src/jimaku/utils.ts | 33 +- src/main.ts | 62 +++- src/main/runtime/anime-browser-runtime.ts | 25 +- .../runtime/stream-playback-metadata.test.ts | 77 +++++ src/main/runtime/stream-playback-metadata.ts | 78 +++++ src/renderer/index.html | 4 + src/renderer/modals/tsukihime.test.ts | 1 + src/renderer/modals/tsukihime.ts | 24 +- src/renderer/utils/dom.ts | 2 + src/types/anime-browser.ts | 5 + 25 files changed, 1725 insertions(+), 69 deletions(-) create mode 100644 src/anime-bridge/episode-metadata.test.ts create mode 100644 src/anime-bridge/episode-metadata.ts create mode 100644 src/core/services/subsync-alass-input.test.ts create mode 100644 src/core/services/subsync-alass-input.ts create mode 100644 src/main/runtime/stream-playback-metadata.test.ts create mode 100644 src/main/runtime/stream-playback-metadata.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index acc5de089..de6543b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,14 @@ ### Fixed +- Split-Cour Season Resolution: 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. This made Mushoku Tensei season 3 resolve 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, since AniList spells the same name inconsistently across a franchise's own entries, and the marker is looked for in every title and synonym rather than just the display title. Note that a wrong match cached before this fix is remembered in `character-dictionaries/anilist-resolution-cache.json` and needs to be removed (or overridden from the character dictionary picker) for the affected series. +- Specials In The Season Chain: A special or OVA sitting in a franchise's sequel chain is now 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. +- Season Resolution Under Rate Limiting: An AniList rate limit or network failure while walking sequel relations is now 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. +- Anime Browser Titles: An episode launched from the anime browser now carries its series, season and episode number through the app instead of a single joined string. Stats groups streams by series (every stream previously landed in one entry named `m3u8`, because the only readable part of the strip-proxy URL was its file extension), rewatching an episode reuses its entry rather than creating a new one, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing instead of leaving them blank, and AniList updates use those fields directly. The mpv title now reads `Series S03E04 - Episode Name`. +- TsukiHime Season: The TsukiHime modal has a Season field, matching Jimaku. It is filled in automatically for anime browser streams, and seasons after the first are included in the search query so a later season's releases are actually found. +- Episode Parsing: The shared title parser now recognizes spelled-out episode labels (`Episode 4`, `第4話`) and a season named in the title (`… Season 3`, `… 2nd Season`, `… S3`), which are put in the Season field instead of being searched for as part of the series name. - Subtitle Sync On Streams: Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. Such a 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 now be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`. +- Subsync With WebVTT Tracks: 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" (and a VTT saved under an `.srt` name died in its SubRip parser instead). Both the sync target and the reference are now rewritten as SRT for alass, keeping the cue text as-is, and the originals are left untouched. - Subsync Tool Discovery: 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/`, 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 Logging: Subsync failures are now written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from. - Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and, on macOS, puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 8abb47878..0c9c7e65a 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -245,6 +245,8 @@ Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first, 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. + ## TsukiHime **"xz binary not found"** diff --git a/src/anime-bridge/episode-metadata.test.ts b/src/anime-bridge/episode-metadata.test.ts new file mode 100644 index 000000000..387b4bb0e --- /dev/null +++ b/src/anime-bridge/episode-metadata.test.ts @@ -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'); +}); diff --git a/src/anime-bridge/episode-metadata.ts b/src/anime-bridge/episode-metadata.ts new file mode 100644 index 000000000..3b935c326 --- /dev/null +++ b/src/anime-bridge/episode-metadata.ts @@ -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), + }; +} diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts index 7ebb077e9..fc6c82bed 100644 --- a/src/animeui/animeui.ts +++ b/src/animeui/animeui.ts @@ -333,6 +333,7 @@ async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpiso animeTitle: selectedAnime.title, episodeUrl: episode.url, episodeName: episode.name, + episodeNumber: episode.number, }); if (result.ok) { diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 21149fc2e..e8c8c1c22 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -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 { diff --git a/src/core/services/anilist/cover-art-fetcher.ts b/src/core/services/anilist/cover-art-fetcher.ts index ac7be780a..7f623d05a 100644 --- a/src/core/services/anilist/cover-art-fetcher.ts +++ b/src/core/services/anilist/cover-art-fetcher.ts @@ -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; } diff --git a/src/core/services/anilist/season-resolver.test.ts b/src/core/services/anilist/season-resolver.test.ts index a5e0b8cd8..bdbdbfc72 100644 --- a/src/core/services/anilist/season-resolver.test.ts +++ b/src/core/services/anilist/season-resolver.test.ts @@ -6,6 +6,7 @@ import { ANILIST_SEASON_SEARCH_QUERY, pickByAirOrder, resolveAnilistSeasonMedia, + splitPartMarker, stripSeasonSuffix, type AnilistSeasonMedia, } from './season-resolver'; @@ -378,3 +379,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 (query: string, variables: Record): Promise => { + if (query === ANILIST_SEASON_RELATIONS_QUERY) { + throw new Error('Too Many Requests.'); + } + return execute(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/, + ); +}); diff --git a/src/core/services/anilist/season-resolver.ts b/src/core/services/anilist/season-resolver.ts index 8bbf9b5d6..c84c5166d 100644 --- a/src/core/services/anilist/season-resolver.ts +++ b/src/core/services/anilist/season-resolver.ts @@ -119,6 +119,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 +171,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] @@ -253,16 +336,21 @@ async function walkSequelChain( season: number, deps: ResolveAnilistSeasonMediaDeps, ): Promise { - // 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([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( ANILIST_SEASON_RELATIONS_QUERY, @@ -299,11 +387,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; } /** @@ -326,13 +428,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 " 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; @@ -407,6 +527,14 @@ export async function resolveAnilistSeasonMedia( ); } + // 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?.( @@ -422,12 +550,6 @@ export async function resolveAnilistSeasonMedia( ); } - // 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})`, ); diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index e9f075dac..8f9589f86 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -4909,3 +4909,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); + } +}); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index d4efbdd0e..d5f0134d9 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -341,6 +341,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; } @@ -374,7 +399,7 @@ function deleteSearchParamsCaseInsensitive(searchParams: URLSearchParams, names: } } -function buildJellyfinMediaPathAliasCandidates(mediaPath: string): string[] { +function buildMediaPathAliasCandidates(mediaPath: string): string[] { const candidates = new Set([mediaPath]); try { const parsed = new URL(mediaPath); @@ -1421,15 +1446,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; @@ -1439,49 +1460,112 @@ 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 = getManualAnimeAssignment(this.db, videoId) ?? 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( @@ -1506,17 +1590,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); @@ -1566,7 +1650,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) { diff --git a/src/core/services/subsync-alass-input.test.ts b/src/core/services/subsync-alass-input.test.ts new file mode 100644 index 000000000..fd6c1b11d --- /dev/null +++ b/src/core/services/subsync-alass-input.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + convertSubtitleForAlass, + formatCuesAsSrt, + needsAlassConversion, + parseTimedCues, +} from './subsync-alass-input'; + +const VTT = [ + 'WEBVTT', + '', + 'NOTE this comment is not a cue', + '', + 'cue-1', + '00:00:01.500 --> 00:00:03.250 line:90% align:center', + 'しっかし', + 'まさかお前が', + '', + '00:01:02.000 --> 00:01:04.000', + 'ゼニスも きっと幸せじゃろう', + '', +].join('\n'); + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'alass-input-test-')); +} + +test('parseTimedCues keeps cue payloads verbatim and skips headers', () => { + const cues = parseTimedCues(VTT); + assert.equal(cues.length, 2); + assert.equal(cues[0]!.start, 1.5); + assert.equal(cues[0]!.end, 3.25); + assert.equal(cues[0]!.text, 'しっかし\nまさかお前が'); + assert.equal(cues[1]!.start, 62); + assert.equal(cues[1]!.text, 'ゼニスも きっと幸せじゃろう'); +}); + +test('parseTimedCues reads VTT timestamps without an hours field', () => { + const cues = parseTimedCues('WEBVTT\n\n01:02.000 --> 01:03.000\nhello\n'); + assert.equal(cues.length, 1); + assert.equal(cues[0]!.start, 62); + assert.equal(cues[0]!.end, 63); +}); + +test('formatCuesAsSrt writes numbered SubRip blocks', () => { + assert.equal( + formatCuesAsSrt(parseTimedCues(VTT)), + '1\n00:00:01,500 --> 00:00:03,250\nしっかし\nまさかお前が\n\n' + + '2\n00:01:02,000 --> 00:01:04,000\nゼニスも きっと幸せじゃろう\n', + ); +}); + +test('needsAlassConversion targets VTT by extension and by content', () => { + assert.equal(needsAlassConversion('/tmp/track-0.vtt', 'WEBVTT\n'), true); + assert.equal(needsAlassConversion('/tmp/track-0.srt', 'WEBVTT\n'), true); + assert.equal(needsAlassConversion('/tmp/track-0.ttml', ''), true); + assert.equal( + needsAlassConversion('/tmp/track-0.srt', '1\n00:00:01,000 --> 00:00:02,000\n'), + false, + ); + assert.equal(needsAlassConversion('/tmp/track-0.ass', '[Script Info]\n'), false); +}); + +test('convertSubtitleForAlass rewrites a VTT track as SRT', () => { + const dir = makeTempDir(); + const source = path.join(dir, 'track-0.vtt'); + fs.writeFileSync(source, VTT); + + const converted = convertSubtitleForAlass(source); + assert.notEqual(converted, null); + assert.equal(converted!.temporary, true); + assert.equal(path.extname(converted!.path), '.srt'); + assert.match(fs.readFileSync(converted!.path, 'utf8'), /00:00:01,500 --> 00:00:03,250/); + + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(path.dirname(converted!.path), { recursive: true, force: true }); +}); + +test('convertSubtitleForAlass converts a VTT payload hiding behind an .srt name', () => { + const dir = makeTempDir(); + const source = path.join(dir, 'remote_track.srt'); + fs.writeFileSync(source, VTT); + + const converted = convertSubtitleForAlass(source); + assert.notEqual(converted, null); + assert.notEqual(converted!.path, source); + assert.equal(fs.readFileSync(converted!.path, 'utf8').startsWith('1\n'), true); + + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(path.dirname(converted!.path), { recursive: true, force: true }); +}); + +test('convertSubtitleForAlass leaves an SRT file alone', () => { + const dir = makeTempDir(); + const source = path.join(dir, 'track-0.srt'); + fs.writeFileSync(source, '1\n00:00:01,000 --> 00:00:02,000\nhello\n'); + + assert.equal(convertSubtitleForAlass(source), null); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('convertSubtitleForAlass reports a file with no readable timings', () => { + const dir = makeTempDir(); + const source = path.join(dir, 'track-0.vtt'); + fs.writeFileSync(source, 'WEBVTT\n\nnot a cue at all\n'); + + assert.throws(() => convertSubtitleForAlass(source), /Could not read subtitle timings/); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/src/core/services/subsync-alass-input.ts b/src/core/services/subsync-alass-input.ts new file mode 100644 index 000000000..d64ee67a1 --- /dev/null +++ b/src/core/services/subsync-alass-input.ts @@ -0,0 +1,155 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { FileExtractionResult } from './subsync-extract'; +import { createLogger } from '../../logger'; + +const logger = createLogger('subsync'); + +/** + * alass picks its parser purely from the file extension, and WebVTT is not one + * of the formats it knows. A `.vtt` reference is therefore treated as a *video* + * ("no audio stream in file"), and the same file renamed to `.srt` dies in the + * SubRip parser on the `WEBVTT` header. Extension-backed anime streams hand out + * VTT almost exclusively, so every alass input is converted to SRT first. + */ +const ALASS_SUBTITLE_EXTENSIONS = new Set(['srt', 'ass', 'ssa', 'sub', 'idx']); + +/** How much of the file is decoded to recognise a WebVTT header. */ +const SNIFF_BYTES = 1024; + +const CUE_TIMING_PATTERN = + /^\s*(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/; + +interface RawCue { + start: number; + end: number; + text: string; +} + +function toSeconds( + hours: string | undefined, + minutes: string, + seconds: string, + millis: string, +): number { + return ( + Number(hours ?? 0) * 3600 + + Number(minutes) * 60 + + Number(seconds) + + Number(millis.padEnd(3, '0')) / 1000 + ); +} + +function formatTimestamp(totalSeconds: number): string { + const clamped = Math.max(0, totalSeconds); + const millis = Math.round(clamped * 1000); + const hours = Math.floor(millis / 3_600_000); + const minutes = Math.floor((millis % 3_600_000) / 60_000); + const seconds = Math.floor((millis % 60_000) / 1000); + const remainder = millis % 1000; + const pad = (value: number, width: number): string => String(value).padStart(width, '0'); + return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(remainder, 3)}`; +} + +/** + * Read timed cues out of a WebVTT (or SRT-shaped) file. + * + * The payload is kept verbatim rather than sanitised: this same file is what + * alass retimes and mpv then loads, so stripping inline tags here would show up + * on screen. Cue identifiers, `NOTE`/`STYLE`/`REGION` blocks and the `WEBVTT` + * header all fall out for free — only lines that follow a timing line are kept. + */ +export function parseTimedCues(content: string): RawCue[] { + const lines = content.replace(/^/, '').split(/\r?\n/); + const cues: RawCue[] = []; + + for (let index = 0; index < lines.length; index += 1) { + const timing = CUE_TIMING_PATTERN.exec(lines[index]!); + if (!timing) continue; + + const start = toSeconds(timing[1], timing[2]!, timing[3]!, timing[4]!); + const end = toSeconds(timing[5], timing[6]!, timing[7]!, timing[8]!); + + const textLines: string[] = []; + index += 1; + while (index < lines.length && lines[index]!.trim().length > 0) { + textLines.push(lines[index]!); + index += 1; + } + + const text = textLines.join('\n').trim(); + if (text.length > 0) { + cues.push({ start, end, text }); + } + } + + return cues; +} + +export function formatCuesAsSrt(cues: RawCue[]): string { + return cues + .map( + (cue, index) => + `${index + 1}\n${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}\n`, + ) + .join('\n'); +} + +function readHead(filePath: string): string { + const handle = fs.openSync(filePath, 'r'); + try { + const buffer = Buffer.alloc(SNIFF_BYTES); + const bytesRead = fs.readSync(handle, buffer, 0, SNIFF_BYTES, 0); + return buffer.subarray(0, bytesRead).toString('utf8'); + } finally { + fs.closeSync(handle); + } +} + +function isWebVttContent(head: string): boolean { + return /^WEBVTT(\s|$)/.test(head.replace(/^/, '').trimStart()); +} + +/** + * Decide whether alass can read the file as-is. + * + * Content wins over the extension in one direction only: a file alass would + * accept by extension still needs converting when it turns out to hold VTT, + * while an unrecognised extension is always converted. Anything else is passed + * through untouched, which also keeps its original charset intact — alass + * detects encodings that a UTF-8 round trip here would mangle. + */ +export function needsAlassConversion(filePath: string, head: string): boolean { + const extension = path.extname(filePath).slice(1).toLowerCase(); + if (!ALASS_SUBTITLE_EXTENSIONS.has(extension)) return true; + return isWebVttContent(head); +} + +/** + * Rewrite a subtitle file as SRT for alass, or return null when alass can + * already read it. The result is a temporary file in its own directory, so + * `cleanupTemporaryFile` can remove it (and keep the retimed output beside it). + */ +export function convertSubtitleForAlass(filePath: string): FileExtractionResult | null { + if (!needsAlassConversion(filePath, readHead(filePath))) return null; + + const cues = parseTimedCues(fs.readFileSync(filePath, 'utf8')); + if (cues.length === 0) { + throw new Error(`Could not read subtitle timings for alass from ${path.basename(filePath)}`); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-alass-')); + const outputPath = path.join(tempDir, `${path.parse(filePath).name}.srt`); + try { + fs.writeFileSync(outputPath, formatCuesAsSrt(cues), 'utf8'); + } catch (error) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch {} + throw error; + } + + logger.info(`Converted ${filePath} to SRT for alass: ${outputPath} (${cues.length} cues)`); + return { path: outputPath, temporary: true }; +} diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index cd1cf1141..f430cb3ea 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -903,6 +903,85 @@ test('runSubsyncManual keeps a retimed secondary track in the secondary slot', a ); }); +test('runSubsyncManual converts VTT stream tracks to SRT before running alass', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-vtt-')); + const alassLogPath = path.join(tmpDir, 'alass-args.log'); + const alassPath = path.join(tmpDir, 'alass.sh'); + const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh'); + const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh'); + const videoPath = 'https://example.com/stream.m3u8'; + const primaryPath = path.join(tmpDir, 'track-1.vtt'); + const sourcePath = path.join(tmpDir, 'track-0.vtt'); + + fs.writeFileSync(primaryPath, 'WEBVTT\n\n00:00:05.000 --> 00:00:06.000\nしっかし\n'); + fs.writeFileSync(sourcePath, 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nWell then\n'); + writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript( + alassPath, + `#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\ncp "$2" "$3"\nexit 0\n`, + ); + + const sentCommands: Array> = []; + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: (payload) => { + sentCommands.push(payload.command); + }, + requestProperty: async (name: string) => { + if (name === 'path') return videoPath; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { + id: 1, + type: 'sub', + selected: true, + external: true, + 'external-filename': primaryPath, + }, + { + id: 2, + type: 'sub', + selected: false, + external: true, + 'external-filename': sourcePath, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + alassPath, + ffsubsyncPath, + ffmpegPath, + }), + }); + + const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps); + + assert.equal(result.ok, true); + const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n'); + assert.equal(alassArgs.length, 3); + for (const arg of alassArgs) { + assert.equal(path.extname(arg), '.srt'); + } + // The originals stay put; only the alass copies are rewritten. + assert.equal(fs.existsSync(primaryPath), true); + assert.equal(fs.existsSync(sourcePath), true); + + const loadedPath = sentCommands[0]?.[1]; + assert.equal(sentCommands[0]?.[0], 'sub-add'); + assert.equal(typeof loadedPath, 'string'); + assert.match(fs.readFileSync(fromShellPath(String(loadedPath)), 'utf8'), /しっかし/); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-')); const alassPath = path.join(tmpDir, 'alass.sh'); diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index 886da5271..37971cc6d 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -21,6 +21,7 @@ import { extractSubtitleTrackToFile, FileExtractionResult, } from './subsync-extract'; +import { convertSubtitleForAlass } from './subsync-alass-input'; import { resolveMpvHttpHeaders, ResolvedMpvHttpHeaders } from './mpv-http-headers'; import { isRemoteMediaPath } from '../../jimaku/utils'; import { createLogger } from '../../logger'; @@ -299,23 +300,45 @@ async function subsyncToReference( track: targetTrack, httpHeaders, }); - const replaceTarget = resolved.replace !== false && !targetExtraction.temporary; - const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget); + + // alass reads neither the target nor the reference as WebVTT, so both are + // rewritten as SRT first; ffsubsync parses VTT itself. A video reference is + // left alone: alass reads its audio, and there are no cues to convert. + const referenceIsVideo = referenceFilePath === context.videoPath; + let convertedTarget: FileExtractionResult | null = null; + let convertedReference: FileExtractionResult | null = null; + if (engine === 'alass') { + try { + convertedTarget = convertSubtitleForAlass(targetExtraction.path); + convertedReference = referenceIsVideo ? null : convertSubtitleForAlass(referenceFilePath); + } catch (error) { + if (convertedTarget) cleanupTemporaryFile(convertedTarget); + cleanupTemporaryFile(targetExtraction); + const message = `alass synchronization failed: ${(error as Error).message}`; + logger.error(message); + return { ok: false, message }; + } + } + + const target = convertedTarget ?? targetExtraction; + const referencePath = convertedReference?.path ?? referenceFilePath; + const replaceTarget = resolved.replace !== false && !target.temporary; + const outputPath = buildRetimedPath(target.path, replaceTarget); logger.info( - `Running ${engine}: target=${targetExtraction.path} reference=${referenceFilePath} output=${outputPath}`, + `Running ${engine}: target=${target.path} reference=${referencePath} output=${outputPath}`, ); try { let result: CommandResult; if (engine === 'alass') { const alassPath = resolveSubsyncExecutable(resolved.alassPath, 'alass'); - result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath); + result = await runAlassSync(alassPath, referencePath, target.path, outputPath); } else { const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync'); result = await runFfsubsyncSync( ffsubsyncPath, context.videoPath, - targetExtraction.path, + target.path, outputPath, context.audioStreamIndex, ); @@ -337,6 +360,8 @@ async function subsyncToReference( message: `Subtitle synchronized with ${engine}`, }; } finally { + if (convertedReference) cleanupTemporaryFile(convertedReference); + if (convertedTarget) cleanupTemporaryFile(convertedTarget, outputPath); cleanupTemporaryFile(targetExtraction, outputPath); } } diff --git a/src/jimaku/utils.ts b/src/jimaku/utils.ts index 17468f468..33ac031a9 100644 --- a/src/jimaku/utils.ts +++ b/src/jimaku/utils.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as childProcess from 'child_process'; import { createLogger } from '../logger'; +import { splitSeasonFromTitle } from '../anime-bridge/episode-metadata'; import { JimakuApiResponse, JimakuConfig, @@ -163,6 +164,28 @@ function matchEpisodeFromName(name: string): { }; } + // Spelled-out labels. Streaming sources name episodes this way ("Episode 4"), + // and the abbreviated `E04` pattern below cannot see them. + const worded = name.match(/(?:^|[\s._-])episode\s*[.#]?\s*(\d{1,3})(?:\D|$)/i); + if (worded && worded.index !== undefined) { + return { + season: null, + episode: Number.parseInt(worded[1]!, 10), + index: worded.index, + confidence: 'medium', + }; + } + + const japanese = name.match(/第\s*(\d{1,3})\s*話/); + if (japanese && japanese.index !== undefined) { + return { + season: null, + episode: Number.parseInt(japanese[1]!, 10), + index: japanese.index, + confidence: 'medium', + }; + } + const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i); if (epOnly && epOnly.index !== undefined) { return { @@ -229,12 +252,14 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo { titlePart = name.slice(0, parsed.index); } - const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath); - const title = cleanupTitle(titlePart || name); + // A season named in the title ("… Season 3") belongs in the season field, not + // glued to the title — otherwise it is searched for as part of the name. + const titleSeason = splitSeasonFromTitle(cleanupTitle(titlePart || name)); + const season = parsed.season ?? titleSeason.season ?? detectSeasonFromDir(normalizedMediaPath); return { - title, - season: seasonFromDir, + title: titleSeason.title, + season, episode: parsed.episode, confidence: parsed.confidence, filename, diff --git a/src/main.ts b/src/main.ts index a6ec0cc9e..e12998f40 100644 --- a/src/main.ts +++ b/src/main.ts @@ -420,6 +420,7 @@ import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update import { guessAnilistMediaInfo, updateAnilistPostWatchProgress, + type AnilistMediaGuess, } from './core/services/anilist/anilist-updater'; import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher'; import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; @@ -486,6 +487,11 @@ import { getJlptDictionarySearchPaths, } from './main/jlpt-runtime'; import { createMediaRuntimeService } from './main/media-runtime'; +import { + createStreamPlaybackMetadataStore, + toAnilistMediaGuess, + toJimakuMediaInfo, +} from './main/runtime/stream-playback-metadata'; import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; @@ -2407,6 +2413,31 @@ const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroup const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions'); const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json'); +/** + * What the anime browser resolved for the stream that is playing. Consulted by + * everything that would otherwise have to parse the mpv title, or — worse — the + * stream URL, which names only the proxy and the container format. + */ +const streamPlaybackMetadata = createStreamPlaybackMetadataStore(); + +/** The stream metadata for whatever mpv currently has open, if it is a stream. */ +function getActiveStreamMetadata() { + return streamPlaybackMetadata.match(appState.currentMediaPath); +} + +/** + * The AniList guess for what is playing. A stream answers from the fields its + * source reported; everything else goes through the usual title parsers. + */ +async function guessAnilistMediaInfoForCurrentMedia( + mediaPath: string | null, + mediaTitle: string | null, +): Promise { + const stream = getActiveStreamMetadata(); + const streamGuess = stream ? toAnilistMediaGuess(stream) : null; + return streamGuess ?? guessAnilistMediaInfo(mediaPath, mediaTitle); +} + const mediaRuntime = createMediaRuntimeService( createBuildMediaRuntimeMainDepsHandler({ isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), @@ -2439,7 +2470,8 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({ getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath, getCurrentMediaTitle: () => appState.currentMediaTitle, resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), - guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), + guessAnilistMediaInfo: (mediaPath, mediaTitle) => + guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle), getNameMatchImagesEnabled: () => configService.getConfig().subtitleStyle.nameMatchImagesEnabled, getCollapsibleSectionOpenState: (section) => configService.getConfig().anilist.characterDictionary.collapsibleSections[section], @@ -3295,6 +3327,22 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({ }, showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }), + onPlaybackMetadata: (metadata) => { + streamPlaybackMetadata.set(metadata); + // Set before mpv reports the path change, so the session that change starts + // is titled and grouped from the source's own listing rather than from the + // proxy URL, whose only readable part is the `.m3u8` extension. + mediaRuntime.updateCurrentMediaTitle(metadata.displayTitle); + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordStreamPlaybackMetadata({ + mediaPath: metadata.mediaPath, + statsPath: metadata.statsPath, + displayTitle: metadata.displayTitle, + seriesTitle: metadata.seriesTitle, + seasonNumber: metadata.seasonNumber, + episodeNumber: metadata.episodeNumber, + }); + }, onBridgeState: (state) => { const window = appState.animeBrowserWindow; if (window && !window.isDestroyed()) { @@ -3751,7 +3799,8 @@ const { mediaRuntime.resolveMediaPathForJimaku(currentMediaPath), getCurrentMediaPath: () => appState.currentMediaPath, getCurrentMediaTitle: () => appState.currentMediaTitle, - guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), + guessAnilistMediaInfo: (mediaPath, mediaTitle) => + guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle), }, processNextRetryUpdateMainDeps: { nextReady: () => anilistUpdateQueue.nextReady(), @@ -5939,8 +5988,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getFieldGroupingResolver: () => getFieldGroupingResolverHandler(), setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => setFieldGroupingResolverHandler(resolver), - parseMediaInfo: (mediaPath: string | null) => - parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)), + parseMediaInfo: (mediaPath: string | null) => { + // A stream already knows its series, season and episode; parsing the + // title back out of a string would only lose what the source told us. + const stream = getActiveStreamMetadata(); + if (stream) return toJimakuMediaInfo(stream); + return parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)); + }, getCurrentMediaPath: () => appState.currentMediaPath, jimakuFetchJson: ( endpoint: string, diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index 6ebc1ef08..e65fd391d 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -18,6 +18,10 @@ import { buildTrackCommands, selectPreferredStream, } from '../../anime-bridge/mpv-playback'; +import { + buildAnimeStreamMetadata, + type AnimeStreamMetadata, +} from '../../anime-bridge/episode-metadata'; import { listExtensionSources, readInstalledExtensions, @@ -87,6 +91,13 @@ export interface AnimeBrowserRuntimeDeps { readMpvProperty?: (name: string) => Promise; showMpvOsd?: (message: string) => void; showVisibleOverlay?: () => void; + /** + * Publishes what is about to play. Called *before* `loadfile` so the title + * and the episode's identity are already known when mpv reports the path + * change — otherwise stats sees only the proxy URL and groups every stream + * under its file extension. + */ + onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void; /** Lets tests drive the pause between `loadfile` and the track commands. */ wait?: (ms: number) => Promise; /** Overrides the filesystem/network the subtitle cache uses. Tests only. */ @@ -616,7 +627,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { : null; try { - const title = `${request.animeTitle} — ${request.episodeName}`; + const metadata = buildAnimeStreamMetadata({ + sourceId: request.sourceId, + animeUrl: request.animeUrl, + animeTitle: request.animeTitle, + episodeUrl: request.episodeUrl, + episodeName: request.episodeName, + episodeNumber: request.episodeNumber ?? null, + mediaPath: stream.url, + }); + const title = metadata.displayTitle; + // Before loadfile: mpv's path change is what starts a stats session, + // and it must find this already recorded. + deps.onPlaybackMetadata?.(metadata); for (const command of buildPlaybackCommands({ stream, title })) { deps.sendMpvCommand(command); } diff --git a/src/main/runtime/stream-playback-metadata.test.ts b/src/main/runtime/stream-playback-metadata.test.ts new file mode 100644 index 000000000..bf80a41b4 --- /dev/null +++ b/src/main/runtime/stream-playback-metadata.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createStreamPlaybackMetadataStore, + toAnilistMediaGuess, + toJimakuMediaInfo, +} from './stream-playback-metadata'; +import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata'; + +function metadata(overrides: Partial = {}): AnimeStreamMetadata { + return { + mediaPath: 'http://127.0.0.1:41234/video/abc.m3u8', + statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4', + seriesTitle: 'Mushoku Tensei: Jobless Reincarnation', + seasonNumber: 3, + episodeNumber: 4, + episodeTitle: null, + displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04', + ...overrides, + }; +} + +test('the store answers for the stream URL and for the stats path', () => { + const store = createStreamPlaybackMetadataStore(); + const current = metadata(); + store.set(current); + + assert.equal(store.match(current.mediaPath), current); + assert.equal(store.match(current.statsPath), current); + assert.equal(store.match(` ${current.mediaPath} `), current); +}); + +test('the store stops answering once the player moves on', () => { + const store = createStreamPlaybackMetadataStore(); + store.set(metadata()); + + assert.equal(store.match('/home/user/Videos/local.mkv'), null); + assert.equal(store.match(null), null); + assert.equal(store.match(''), null); + + store.clear(); + assert.equal(store.match(metadata().mediaPath), null); +}); + +test('toJimakuMediaInfo prefills the modals with the source-reported fields', () => { + assert.deepEqual(toJimakuMediaInfo(metadata()), { + title: 'Mushoku Tensei: Jobless Reincarnation', + season: 3, + episode: 4, + confidence: 'high', + filename: 'Mushoku Tensei: Jobless Reincarnation S03E04', + rawTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04', + }); +}); + +test('toJimakuMediaInfo drops to low confidence when there is no episode', () => { + const info = toJimakuMediaInfo(metadata({ episodeNumber: null, seasonNumber: null })); + assert.equal(info.episode, null); + assert.equal(info.confidence, 'low'); + assert.equal(info.title, 'Mushoku Tensei: Jobless Reincarnation'); +}); + +test('toAnilistMediaGuess reports the stream fields verbatim', () => { + assert.deepEqual(toAnilistMediaGuess(metadata()), { + title: 'Mushoku Tensei: Jobless Reincarnation', + season: 3, + episode: 4, + source: 'stream', + }); +}); + +test('toAnilistMediaGuess declines a special so the caller can fall back', () => { + // AniList progress counts whole episodes; a 6.5 special is not one of them. + assert.equal(toAnilistMediaGuess(metadata({ episodeNumber: 6.5 })), null); + assert.equal(toAnilistMediaGuess(metadata({ episodeNumber: null })), null); + assert.equal(toAnilistMediaGuess(metadata({ seriesTitle: '' })), null); +}); diff --git a/src/main/runtime/stream-playback-metadata.ts b/src/main/runtime/stream-playback-metadata.ts new file mode 100644 index 000000000..299b5945b --- /dev/null +++ b/src/main/runtime/stream-playback-metadata.ts @@ -0,0 +1,78 @@ +import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata'; +import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; +import type { JimakuMediaInfo } from '../../types'; + +/** + * Holds what the anime browser resolved for the episode currently streaming. + * + * Consumers otherwise have to re-derive the series and episode from the mpv + * title, and some of them only ever see the stream URL, which carries no title + * at all. Keeping the resolved fields lets each of them ask instead of guess. + */ +export interface StreamPlaybackMetadataStore { + set: (metadata: AnimeStreamMetadata) => void; + clear: () => void; + /** + * The metadata for `mediaPath`, or null when the player has moved on to + * something else. Matching on the path is what makes this self-expiring — + * there is no teardown hook to miss. + */ + match: (mediaPath: string | null) => AnimeStreamMetadata | null; +} + +export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore { + let current: AnimeStreamMetadata | null = null; + + return { + set(metadata: AnimeStreamMetadata): void { + current = metadata; + }, + clear(): void { + current = null; + }, + match(mediaPath: string | null): AnimeStreamMetadata | null { + if (!current) return null; + const trimmed = typeof mediaPath === 'string' ? mediaPath.trim() : ''; + if (!trimmed) return null; + // The stats path is accepted too: stats rewrites the volatile stream URL + // to it, so callers reading from there still resolve. + return trimmed === current.mediaPath || trimmed === current.statsPath ? current : null; + }, + }; +} + +/** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */ +function wholeEpisode(episode: number | null): number | null { + return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null; +} + +/** + * The subtitle modals' prefill. `high` confidence is honest here: these fields + * came from the source's own listing rather than from a filename guess, so the + * modals may search on them without waiting for the user to confirm. + */ +export function toJimakuMediaInfo(metadata: AnimeStreamMetadata): JimakuMediaInfo { + return { + title: metadata.seriesTitle, + season: metadata.seasonNumber, + episode: wholeEpisode(metadata.episodeNumber), + confidence: metadata.episodeNumber !== null ? 'high' : 'low', + filename: metadata.displayTitle, + rawTitle: metadata.displayTitle, + }; +} + +/** + * The AniList guess, or null when there is no episode to report — in which case + * the caller should fall back to parsing the title as usual. + */ +export function toAnilistMediaGuess(metadata: AnimeStreamMetadata): AnilistMediaGuess | null { + const episode = wholeEpisode(metadata.episodeNumber); + if (!metadata.seriesTitle || episode === null) return null; + return { + title: metadata.seriesTitle, + season: metadata.seasonNumber, + episode, + source: 'stream', + }; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 49c090275..16f5aca26 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -148,6 +148,10 @@ Title +